diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCache.kt index 1a8cf64c42..67b86c2824 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCache.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.images import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Matrix +import com.vitorpamplona.amethyst.commons.util.deleteOrWarn import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.sha256.sha256 @@ -68,9 +69,7 @@ class ThumbnailDiskCache( BitmapFactory.decodeFile(file.absolutePath) } catch (e: Exception) { Log.w("ThumbnailDiskCache", "Failed to decode cached thumbnail, deleting: ${file.absolutePath}", e) - if (!file.delete()) { - Log.w("ThumbnailDiskCache") { "Failed to delete corrupt cache file: ${file.absolutePath}" } - } + file.deleteOrWarn("ThumbnailDiskCache", "corrupt cache file") null } } @@ -155,9 +154,7 @@ class ThumbnailDiskCache( scaled.recycle() if (!tempFile.renameTo(finalFile)) { Log.w("ThumbnailDiskCache") { "Failed to rename temp thumbnail to final: ${tempFile.absolutePath}" } - if (!tempFile.delete()) { - Log.w("ThumbnailDiskCache") { "Failed to delete temp thumbnail: ${tempFile.absolutePath}" } - } + tempFile.deleteOrWarn("ThumbnailDiskCache", "temp thumbnail") return false } @@ -193,7 +190,7 @@ class ThumbnailDiskCache( files .sortedBy { it.lastModified() } .take(files.size - maxEntries) - .forEach { it.delete() } + .forEach { it.deleteOrWarn("ThumbnailDiskCache", "thumbnail") } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt index 39c0e39194..40c5b3919a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt @@ -28,6 +28,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.util.deleteOrWarn import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.uploads.CompressorQuality import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator @@ -155,9 +156,8 @@ class VoiceReplyViewModel : ViewModel() { private fun deleteVoiceLocalFile() { voiceLocalFile?.let { file -> try { - if (file.exists()) { - file.delete() - Log.d("VoiceReplyViewModel") { "Deleted voice file: ${file.absolutePath}" } + if (file.deleteOrWarn("VoiceReplyViewModel", "voice file")) { + Log.d("VoiceReplyViewModel") { "Voice file removed or already gone: ${file.absolutePath}" } } } catch (e: Exception) { Log.w("VoiceReplyViewModel", "Failed to delete voice file: ${file.absolutePath}", e) diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/FileDeletion.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/FileDeletion.kt new file mode 100644 index 0000000000..6ca8b99870 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/FileDeletion.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.util + +import com.vitorpamplona.quartz.utils.Log +import java.io.File + +/** + * Deletes this file, warning on real failures only. [File.delete] returns false + * both when deletion genuinely fails and when the file is already gone (e.g. + * removed by a concurrent eviction in another thread or process); only the + * former deserves a warning. + * + * @param tag the log tag of the calling component + * @param what a short noun for the log message, e.g. "thumbnail" or "blob" + * @return true when the file no longer exists, whether this call deleted it or + * it was already gone; false when it still exists and could not be deleted. + */ +fun File.deleteOrWarn( + tag: String, + what: String, +): Boolean { + if (delete() || !exists()) return true + Log.w(tag) { "Failed to delete $what $absolutePath" } + return false +} diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt index 76620aeea7..00772efcec 100644 --- a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.keystorage import com.github.javakeyring.BackendNotSupportedException import com.github.javakeyring.Keyring import com.github.javakeyring.PasswordAccessException +import com.vitorpamplona.amethyst.commons.util.deleteOrWarn import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -228,7 +229,7 @@ actual class SecureKeyStorage private actual constructor() { if (existed) { if (data.isEmpty()) { - fallbackFile.delete() + fallbackFile.deleteOrWarn("SecureKeyStorage", "fallback key file") } else { atomicWriteFallbackData(fallbackFile, data) } @@ -275,10 +276,8 @@ actual class SecureKeyStorage private actual constructor() { StandardCopyOption.REPLACE_EXISTING, ) } finally { - // Clean up temp file if it still exists - if (tempFile.exists()) { - tempFile.delete() - } + // Clean up any leftover temp file + tempFile.deleteOrWarn("SecureKeyStorage", "temp key file") } } diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadOrchestrator.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadOrchestrator.kt index 1c214a4a94..058dc558d7 100644 --- a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadOrchestrator.kt +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/UploadOrchestrator.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.commons.service.upload import com.vitorpamplona.amethyst.commons.service.upload.ImageReencoder.ReencodeResult +import com.vitorpamplona.amethyst.commons.util.deleteOrWarn import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult @@ -144,8 +145,8 @@ class UploadOrchestrator( // Eager cleanup of every intermediate. NonCancellable so a // user-cancelled upload still cleans up its temps. withContext(NonCancellable) { - strippedTemp?.delete() - reencodedTemp?.delete() + strippedTemp?.deleteOrWarn("UploadOrchestrator", "stripped temp") + reencodedTemp?.deleteOrWarn("UploadOrchestrator", "reencoded temp") } } } diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/util/FilePermissions.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/util/FilePermissions.kt new file mode 100644 index 0000000000..0ebbd1861b --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/util/FilePermissions.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.util + +import com.vitorpamplona.quartz.utils.Log +import java.io.File +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermission + +/** + * Restricts this file or directory to owner-only access (600 for files, + * 700 for directories), best-effort. + * + * Silent on filesystems without POSIX permissions (Windows), where the user + * profile's NTFS ACLs apply instead; warns when a POSIX filesystem refuses. + * + * @param tag the log tag of the calling component + */ +fun File.restrictToOwner(tag: String) { + try { + val permissions = + if (isDirectory) { + setOf( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE, + ) + } else { + setOf( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + ) + } + Files.setPosixFilePermissions(toPath(), permissions) + } catch (_: UnsupportedOperationException) { + // Windows: no POSIX permissions; the user profile's NTFS ACLs apply instead. + } catch (e: Exception) { + Log.w(tag, "Could not restrict permissions on $absolutePath", e) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/util/FileDeletionTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/util/FileDeletionTest.kt new file mode 100644 index 0000000000..b9f2ed652d --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/util/FileDeletionTest.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.util + +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class FileDeletionTest { + private val createdFiles = mutableListOf() + + @AfterTest + fun cleanup() { + createdFiles.asReversed().forEach { it.delete() } + createdFiles.clear() + } + + private fun tempFile(): File = + File.createTempFile("file-deletion-test", ".tmp").also { + createdFiles.add(it) + } + + @Test + fun deletesExistingFileAndReturnsTrue() { + val file = tempFile() + assertTrue(file.exists()) + + assertTrue(file.deleteOrWarn("FileDeletionTest", "temp file")) + assertFalse(file.exists()) + } + + @Test + fun alreadyGoneFileIsSuccess() { + val file = tempFile() + assertTrue(file.delete()) + + // Simulates a concurrent eviction: the file vanished before our delete. + assertTrue(file.deleteOrWarn("FileDeletionTest", "temp file")) + } + + @Test + fun undeletableFileReturnsFalseAndKeepsFile() { + // A non-empty directory cannot be deleted, and still exists afterwards — + // the genuine-failure branch. + val dir = Files.createTempDirectory("file-deletion-test").toFile() + val child = File(dir, "child.txt").apply { writeText("keeps dir non-empty") } + createdFiles.add(dir) + createdFiles.add(child) + + assertFalse(dir.deleteOrWarn("FileDeletionTest", "non-empty dir")) + assertTrue(dir.exists()) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/util/FilePermissionsTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/util/FilePermissionsTest.kt new file mode 100644 index 0000000000..6640ffa76f --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/util/FilePermissionsTest.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.util + +import java.io.File +import java.nio.file.FileSystems +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermission +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class FilePermissionsTest { + private val createdFiles = mutableListOf() + + @AfterTest + fun cleanup() { + createdFiles.asReversed().forEach { it.delete() } + createdFiles.clear() + } + + private fun isPosix() = FileSystems.getDefault().supportedFileAttributeViews().contains("posix") + + @Test + fun restrictsFileToOwnerReadWrite() { + if (!isPosix()) return // Windows: restrictToOwner is documented as a silent no-op + + val file = File.createTempFile("file-permissions-test", ".tmp") + createdFiles.add(file) + + file.restrictToOwner("FilePermissionsTest") + + assertEquals( + setOf(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE), + Files.getPosixFilePermissions(file.toPath()), + ) + } + + @Test + fun restrictsDirectoryToOwnerReadWriteExecute() { + if (!isPosix()) return + + val dir = Files.createTempDirectory("file-permissions-test").toFile() + createdFiles.add(dir) + + dir.restrictToOwner("FilePermissionsTest") + + assertEquals( + setOf( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE, + ), + Files.getPosixFilePermissions(dir.toPath()), + ) + } + + @Test + fun missingFileDoesNotThrow() { + val ghost = File(System.getProperty("java.io.tmpdir"), "file-permissions-test-missing-${System.nanoTime()}") + + // Best-effort contract: failures are logged, never thrown. + ghost.restrictToOwner("FilePermissionsTest") + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt index b5c4e5598c..068b5d1770 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage import com.vitorpamplona.amethyst.commons.keystorage.SecureStorageException import com.vitorpamplona.amethyst.commons.model.account.AccountInfo import com.vitorpamplona.amethyst.commons.model.account.SignerType +import com.vitorpamplona.amethyst.commons.util.deleteOrWarn import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey @@ -260,10 +261,9 @@ class AccountManager internal constructor( suspend fun loadSavedAccount(): Result = try { - // Clean up legacy files (one-time, silent) - File(amethystDir, "last_account.txt").delete() - File(amethystDir, "bunker_uri.txt").delete() - File(amethystDir, "nwc_connection.txt").delete() + // Clean up legacy files (one-time) + listOf("last_account.txt", "bunker_uri.txt", "nwc_connection.txt") + .forEach { File(amethystDir, it).deleteOrWarn("AccountManager", "legacy file") } // Single source of truth: accounts.json.enc val activeNpub = diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/DesktopAccountStorage.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/DesktopAccountStorage.kt index 40c32e2ad4..3f6ff53ec2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/DesktopAccountStorage.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/DesktopAccountStorage.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage import com.vitorpamplona.amethyst.commons.model.account.AccountInfo import com.vitorpamplona.amethyst.commons.model.account.AccountStorage import com.vitorpamplona.amethyst.commons.model.account.SignerType +import com.vitorpamplona.amethyst.commons.util.deleteOrWarn import com.vitorpamplona.quartz.utils.Log import java.io.File import java.nio.file.Files @@ -166,7 +167,7 @@ class DesktopAccountStorage( val backup = File(file.parent, "accounts.json.enc.corrupt.${System.currentTimeMillis()}") java.nio.file.Files .copy(file.toPath(), backup.toPath()) - file.delete() + file.deleteOrWarn("DesktopAccountStorage", "corrupt accounts file") backup.absolutePath } catch (_: Exception) { null diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/relay/LocalRelayStore.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/relay/LocalRelayStore.kt index 2dea705f9c..566df9e006 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/relay/LocalRelayStore.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/relay/LocalRelayStore.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.desktop.relay import com.vitorpamplona.amethyst.commons.service.BasicBundledInsert +import com.vitorpamplona.amethyst.commons.util.deleteOrWarn import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -276,7 +277,7 @@ class LocalRelayStore( private fun deleteDbFiles(path: String) { listOf("", "-wal", "-shm", "-journal").forEach { suffix -> - File(path + suffix).delete() + File(path + suffix).deleteOrWarn("LocalRelayStore", "relay db file") } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/drafts/DesktopDraftStore.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/drafts/DesktopDraftStore.kt index ac0dbaade0..536802c6be 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/drafts/DesktopDraftStore.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/drafts/DesktopDraftStore.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.desktop.service.drafts import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.amethyst.commons.util.deleteOrWarn import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -174,7 +175,7 @@ class DesktopDraftStore( suspend fun deleteDraft(slug: String) { val safeSlug = sanitizeSlug(slug) mutex.withLock { - File(draftsDir, "$safeSlug.md").delete() + File(draftsDir, "$safeSlug.md").deleteOrWarn("DesktopDraftStore", "draft file") val index = loadIndexMap() index.remove(safeSlug) @@ -249,7 +250,7 @@ class DesktopDraftStore( StandardCopyOption.REPLACE_EXISTING, ) } finally { - if (tempFile.exists()) tempFile.delete() + tempFile.deleteOrWarn("DesktopDraftStore", "temp draft file") } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt index 09cc733d95..a7d28d7b9d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.desktop.service.media import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.toComposeImageBitmap +import com.vitorpamplona.amethyst.commons.util.deleteOrWarn import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -137,7 +138,7 @@ object VideoThumbnailCache { // a persistent cache hit (decoders may fail on every // retry against a half-MP4). Discard so the next request // re-downloads from scratch. - if (!downloaded.persistable) downloaded.file.delete() + if (!downloaded.persistable) downloaded.file.deleteOrWarn("VideoThumbnailCache", "truncated video chunk") } } } @@ -166,7 +167,7 @@ object VideoThumbnailCache { val hash = sha1Hex(url) val cached = File(downloadCacheDir, "$hash.mp4") if (cached.length() > 0L) return Download(cached, persistable = true) - if (cached.exists()) cached.delete() + cached.deleteOrWarn("VideoThumbnailCache", "empty cached chunk") var wrote = false var rangeHonored = false @@ -181,7 +182,7 @@ object VideoThumbnailCache { } } if (!wrote || cached.length() == 0L) { - cached.delete() + cached.deleteOrWarn("VideoThumbnailCache", "empty cached chunk") return null } return Download(cached, persistable = rangeHonored) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/tor/DesktopTorManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/tor/DesktopTorManager.kt index 210d2f2777..2742782a25 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/tor/DesktopTorManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/tor/DesktopTorManager.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.desktop.tor import com.vitorpamplona.amethyst.commons.tor.ITorManager import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus import com.vitorpamplona.amethyst.commons.tor.TorType +import com.vitorpamplona.amethyst.commons.util.restrictToOwner import com.vitorpamplona.quartz.utils.Log import io.matthewnelson.kmp.tor.resource.exec.tor.ResourceLoaderTorExec import io.matthewnelson.kmp.tor.runtime.Action.Companion.startDaemonAsync @@ -190,13 +191,8 @@ class DesktopTorManager( private fun desktopEnvironment(): TorRuntime.Environment { val appDir = torDataDirectory() appDir.mkdirs() - // Restrict permissions to owner only (700) - appDir.setReadable(false, false) - appDir.setReadable(true, true) - appDir.setWritable(false, false) - appDir.setWritable(true, true) - appDir.setExecutable(false, false) - appDir.setExecutable(true, true) + // Owner-only (700) — Tor state includes onion keys. + appDir.restrictToOwner("DesktopTorManager") return TorRuntime.Environment.Builder( workDirectory = appDir.resolve("work"), diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt index fedaa0a6c5..3c36d63e95 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt @@ -68,6 +68,7 @@ import com.vitorpamplona.amethyst.commons.service.upload.CompressionQuality import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator import com.vitorpamplona.amethyst.commons.service.upload.UploadResult import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar +import com.vitorpamplona.amethyst.commons.util.deleteOrWarn import com.vitorpamplona.amethyst.desktop.DesktopPreferences import com.vitorpamplona.amethyst.desktop.ImageCompressionStore import com.vitorpamplona.amethyst.desktop.account.AccountState @@ -282,7 +283,7 @@ fun ComposeNoteDialog( // User opted out of the compressed // version — drop the temp before we // ship the original. - item.compressedFile.delete() + item.compressedFile.deleteOrWarn("ComposeNoteDialog", "compressed temp") orchestrator.upload( file = file, alt = null, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/CompressionPreview.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/CompressionPreview.kt index 66698f4b92..75064dd991 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/CompressionPreview.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/CompressionPreview.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.service.upload.ImageFormatSniffer import com.vitorpamplona.amethyst.commons.service.upload.ImageReencoder import com.vitorpamplona.amethyst.commons.service.upload.ImageReencoder.PassReason import com.vitorpamplona.amethyst.commons.service.upload.ImageReencoder.ReencodeResult +import com.vitorpamplona.amethyst.commons.util.deleteOrWarn import java.io.File import javax.imageio.ImageIO @@ -163,7 +164,7 @@ suspend fun buildPreview( fun cleanupPreviewTemps(items: List) { items.forEach { item -> if (item is PreviewItem.Reencoded) { - item.compressedFile.delete() + item.compressedFile.deleteOrWarn("CompressionPreview", "compressed temp") } } } diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBlobCache.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBlobCache.kt index a0b2636a65..3439f537dc 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBlobCache.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBlobCache.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.amethyst.napplethost +import com.vitorpamplona.amethyst.commons.util.deleteOrWarn import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.sha256.sha256 import java.io.File @@ -55,8 +55,8 @@ class NappletBlobCache( dir.mkdirs() val tmp = File(dir, "$sha256.tmp.${System.nanoTime()}") tmp.writeBytes(bytes) - if (!tmp.renameTo(target) && !tmp.delete()) { - Log.w("NappletBlobCache") { "Failed to delete leftover temp file ${tmp.absolutePath} after a failed rename" } + if (!tmp.renameTo(target)) { + tmp.deleteOrWarn("NappletBlobCache", "leftover temp file") } } } @@ -64,13 +64,18 @@ class NappletBlobCache( /** Best-effort eviction: if the store exceeds [maxBytes], delete oldest blobs until under it. */ fun trimToSize(maxBytes: Long) { runCatching { - val files = dir.listFiles()?.filter { it.isFile && !it.name.contains(".tmp.") } ?: return - var total = files.sumOf { it.length() } + val files = + dir + .listFiles() + ?.filter { it.isFile && !it.name.contains(".tmp.") } + ?.map { Triple(it, it.length(), it.lastModified()) } ?: return + var total = files.sumOf { it.second } if (total <= maxBytes) return - files.sortedBy { it.lastModified() }.forEach { f -> + files.sortedBy { it.third }.forEach { (f, length, _) -> if (total <= maxBytes) return - total -= f.length() - f.delete() + if (f.deleteOrWarn("NappletBlobCache", "blob")) { + total -= length + } } } }