From c3d6c054821a404c5aff6652f85bd417fda58c3a Mon Sep 17 00:00:00 2001 From: davotoula Date: Fri, 3 Jul 2026 22:33:38 +0200 Subject: [PATCH 1/5] fix: handle File.delete() return values in cache eviction and voice cleanup --- .../amethyst/service/images/ThumbnailDiskCache.kt | 6 +++++- .../ui/screen/loggedIn/home/VoiceReplyViewModel.kt | 7 +++++-- .../amethyst/napplethost/NappletBlobCache.kt | 8 ++++++-- 3 files changed, 16 insertions(+), 5 deletions(-) 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..208150293b 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 @@ -193,7 +193,11 @@ class ThumbnailDiskCache( files .sortedBy { it.lastModified() } .take(files.size - maxEntries) - .forEach { it.delete() } + .forEach { + if (!it.delete()) { + Log.w("ThumbnailDiskCache") { "Failed to evict thumbnail ${it.absolutePath}" } + } + } } } 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..4113ec5a08 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 @@ -156,8 +156,11 @@ class VoiceReplyViewModel : ViewModel() { voiceLocalFile?.let { file -> try { if (file.exists()) { - file.delete() - Log.d("VoiceReplyViewModel") { "Deleted voice file: ${file.absolutePath}" } + if (file.delete()) { + Log.d("VoiceReplyViewModel") { "Deleted voice file: ${file.absolutePath}" } + } else { + Log.w("VoiceReplyViewModel") { "Failed to delete voice file: ${file.absolutePath}" } + } } } catch (e: Exception) { Log.w("VoiceReplyViewModel", "Failed to delete voice file: ${file.absolutePath}", e) 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..ecd61878c8 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBlobCache.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBlobCache.kt @@ -69,8 +69,12 @@ class NappletBlobCache( if (total <= maxBytes) return files.sortedBy { it.lastModified() }.forEach { f -> if (total <= maxBytes) return - total -= f.length() - f.delete() + val length = f.length() + if (f.delete()) { + total -= length + } else { + Log.w("NappletBlobCache") { "Failed to evict blob ${f.absolutePath}" } + } } } } From 2b9ffb4849c422b03bf149b8e6c1c15480810d03 Mon Sep 17 00:00:00 2001 From: davotoula Date: Fri, 3 Jul 2026 22:44:20 +0200 Subject: [PATCH 2/5] Code review: - extract shared File.deleteOrWarn helper for cache eviction --- .../service/images/ThumbnailDiskCache.kt | 7 +-- .../loggedIn/home/VoiceReplyViewModel.kt | 5 +-- .../amethyst/commons/util/FileDeletion.kt | 44 +++++++++++++++++++ .../amethyst/napplethost/NappletBlobCache.kt | 16 ++++--- 4 files changed, 57 insertions(+), 15 deletions(-) create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/FileDeletion.kt 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 208150293b..32b1c1fe70 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 @@ -193,11 +194,7 @@ class ThumbnailDiskCache( files .sortedBy { it.lastModified() } .take(files.size - maxEntries) - .forEach { - if (!it.delete()) { - Log.w("ThumbnailDiskCache") { "Failed to evict thumbnail ${it.absolutePath}" } - } - } + .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 4113ec5a08..ce3e28411b 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 @@ -156,10 +157,8 @@ class VoiceReplyViewModel : ViewModel() { voiceLocalFile?.let { file -> try { if (file.exists()) { - if (file.delete()) { + if (file.deleteOrWarn("VoiceReplyViewModel", "voice file")) { Log.d("VoiceReplyViewModel") { "Deleted voice file: ${file.absolutePath}" } - } else { - Log.w("VoiceReplyViewModel") { "Failed to delete voice file: ${file.absolutePath}" } } } } catch (e: Exception) { 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/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBlobCache.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBlobCache.kt index ecd61878c8..baf1109759 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBlobCache.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBlobCache.kt @@ -20,6 +20,7 @@ */ 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 @@ -64,16 +65,17 @@ 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 { it to it.length() } ?: return + var total = files.sumOf { it.second } if (total <= maxBytes) return - files.sortedBy { it.lastModified() }.forEach { f -> + files.sortedBy { it.first.lastModified() }.forEach { (f, length) -> if (total <= maxBytes) return - val length = f.length() - if (f.delete()) { + if (f.deleteOrWarn("NappletBlobCache", "blob")) { total -= length - } else { - Log.w("NappletBlobCache") { "Failed to evict blob ${f.absolutePath}" } } } } From 27650f2f770fec3f5332cbcbf535b70d252498e7 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 4 Jul 2026 14:09:29 +0200 Subject: [PATCH 3/5] fix: handle remaining unchecked File.delete() and Tor dir permission results --- .../commons/keystorage/SecureKeyStorage.kt | 3 +- .../service/upload/UploadOrchestrator.kt | 5 ++-- .../desktop/account/AccountManager.kt | 7 +++-- .../desktop/account/DesktopAccountStorage.kt | 3 +- .../amethyst/desktop/relay/LocalRelayStore.kt | 3 +- .../service/drafts/DesktopDraftStore.kt | 5 ++-- .../service/media/VideoThumbnailCache.kt | 7 +++-- .../amethyst/desktop/tor/DesktopTorManager.kt | 28 ++++++++++++++----- .../amethyst/desktop/ui/ComposeNoteDialog.kt | 3 +- .../desktop/ui/media/CompressionPreview.kt | 3 +- 10 files changed, 45 insertions(+), 22 deletions(-) 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..bcfe4a3509 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 @@ -277,7 +278,7 @@ actual class SecureKeyStorage private actual constructor() { } finally { // Clean up temp file if it still exists if (tempFile.exists()) { - tempFile.delete() + 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/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..66b0924a06 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 @@ -261,9 +262,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() + File(amethystDir, "last_account.txt").deleteOrWarn("AccountManager", "legacy file") + File(amethystDir, "bunker_uri.txt").deleteOrWarn("AccountManager", "legacy file") + File(amethystDir, "nwc_connection.txt").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..8271970177 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() + if (tempFile.exists()) 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..be0e10bb3a 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() + if (cached.exists()) 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..e8881b6375 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 @@ -46,6 +46,8 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import java.io.File +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermission /** * Desktop Tor daemon manager using kmp-tor. @@ -190,13 +192,7 @@ 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) + restrictToOwner(appDir) return TorRuntime.Environment.Builder( workDirectory = appDir.resolve("work"), @@ -205,6 +201,24 @@ class DesktopTorManager( ) {} } + /** Restricts [dir] to owner only (700) — Tor state includes onion keys. */ + private fun restrictToOwner(dir: File) { + try { + Files.setPosixFilePermissions( + dir.toPath(), + setOf( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE, + ), + ) + } catch (e: UnsupportedOperationException) { + // Windows: no POSIX permissions; the user profile's NTFS ACLs apply instead. + } catch (e: Exception) { + Log.w("DesktopTorManager", "Could not restrict permissions on ${dir.absolutePath}", e) + } + } + /** OS-specific data directory for Tor. */ internal fun torDataDirectory(): File { val osName = System.getProperty("os.name", "").lowercase() 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") } } } From 206c0979b1cf56c3d553517ca9850dab4ecb126d Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 4 Jul 2026 14:34:05 +0200 Subject: [PATCH 4/5] =?UTF-8?q?Code=20review:=20-=20align=20voice-file=20d?= =?UTF-8?q?ebug=20log=20with=20deleteOrWarn's=20is-gone=20contract=20-=20C?= =?UTF-8?q?onvert=20the=20delete-then-warn=20sites=20the=20sweep=20left=20?= =?UTF-8?q?hand-rolled=20in=20already=20=20=20touched=20files:=20Thumbnail?= =?UTF-8?q?DiskCache=20corrupt-file=20and=20temp-thumbnail=20cleanup,=20?= =?UTF-8?q?=20=20NappletBlobCache.put=20leftover=20temp,=20and=20SecureKey?= =?UTF-8?q?Storage's=20bare=20delete=20of=20=20=20the=20fallback=20key=20f?= =?UTF-8?q?ile=20(the=20highest-stakes=20delete=20in=20that=20file).=20-?= =?UTF-8?q?=20Drop=20the=20exists()=20guards=20left=20layered=20over=20del?= =?UTF-8?q?eteOrWarn=20=E2=80=94=20the=20helper=20=20=20already=20treats?= =?UTF-8?q?=20an=20absent=20file=20as=20silent=20success.=20-=20Collapse?= =?UTF-8?q?=20AccountManager's=20legacy-file=20triple=20into=20a=20loop=20?= =?UTF-8?q?and=20drop=20the=20=20=20stale=20"silent"=20from=20its=20commen?= =?UTF-8?q?t.=20-=20Snapshot=20lastModified=20alongside=20length=20in=20Na?= =?UTF-8?q?ppletBlobCache.trimToSize=20so=20=20=20sortedBy=20compares=20in?= =?UTF-8?q?-memory=20values=20instead=20of=20stat-ing=20per=20comparison.?= =?UTF-8?q?=20-=20Promote=20DesktopTorManager's=20private=20restrictToOwne?= =?UTF-8?q?r=20into=20a=20shared=20=20=20File.restrictToOwner(tag)=20in=20?= =?UTF-8?q?commons=20(600=20files=20/=20700=20dirs)=20=E2=80=94=20the=20re?= =?UTF-8?q?po's=20=20=20sixth=20private=20copy=20of=20this=20pattern=20was?= =?UTF-8?q?=20one=20too=20many;=20the=20remaining=20copies=20=20=20can=20m?= =?UTF-8?q?igrate=20incrementally?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/images/ThumbnailDiskCache.kt | 8 +-- .../loggedIn/home/VoiceReplyViewModel.kt | 6 +- .../commons/keystorage/SecureKeyStorage.kt | 8 +-- .../amethyst/commons/util/FilePermissions.kt | 58 +++++++++++++++++++ .../desktop/account/AccountManager.kt | 7 +-- .../service/drafts/DesktopDraftStore.kt | 2 +- .../service/media/VideoThumbnailCache.kt | 2 +- .../amethyst/desktop/tor/DesktopTorManager.kt | 24 +------- .../amethyst/napplethost/NappletBlobCache.kt | 9 ++- 9 files changed, 77 insertions(+), 47 deletions(-) create mode 100644 commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/util/FilePermissions.kt 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 32b1c1fe70..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 @@ -69,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 } } @@ -156,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 } 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 ce3e28411b..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 @@ -156,10 +156,8 @@ class VoiceReplyViewModel : ViewModel() { private fun deleteVoiceLocalFile() { voiceLocalFile?.let { file -> try { - if (file.exists()) { - if (file.deleteOrWarn("VoiceReplyViewModel", "voice file")) { - 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/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt index bcfe4a3509..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 @@ -229,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) } @@ -276,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.deleteOrWarn("SecureKeyStorage", "temp key file") - } + // Clean up any leftover temp file + tempFile.deleteOrWarn("SecureKeyStorage", "temp key file") } } 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/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt index 66b0924a06..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 @@ -261,10 +261,9 @@ class AccountManager internal constructor( suspend fun loadSavedAccount(): Result = try { - // Clean up legacy files (one-time, silent) - File(amethystDir, "last_account.txt").deleteOrWarn("AccountManager", "legacy file") - File(amethystDir, "bunker_uri.txt").deleteOrWarn("AccountManager", "legacy file") - File(amethystDir, "nwc_connection.txt").deleteOrWarn("AccountManager", "legacy file") + // 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/service/drafts/DesktopDraftStore.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/drafts/DesktopDraftStore.kt index 8271970177..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 @@ -250,7 +250,7 @@ class DesktopDraftStore( StandardCopyOption.REPLACE_EXISTING, ) } finally { - if (tempFile.exists()) tempFile.deleteOrWarn("DesktopDraftStore", "temp draft file") + 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 be0e10bb3a..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 @@ -167,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.deleteOrWarn("VideoThumbnailCache", "empty cached chunk") + cached.deleteOrWarn("VideoThumbnailCache", "empty cached chunk") var wrote = false var rangeHonored = false 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 e8881b6375..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 @@ -46,8 +47,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import java.io.File -import java.nio.file.Files -import java.nio.file.attribute.PosixFilePermission /** * Desktop Tor daemon manager using kmp-tor. @@ -192,7 +191,8 @@ class DesktopTorManager( private fun desktopEnvironment(): TorRuntime.Environment { val appDir = torDataDirectory() appDir.mkdirs() - restrictToOwner(appDir) + // Owner-only (700) — Tor state includes onion keys. + appDir.restrictToOwner("DesktopTorManager") return TorRuntime.Environment.Builder( workDirectory = appDir.resolve("work"), @@ -201,24 +201,6 @@ class DesktopTorManager( ) {} } - /** Restricts [dir] to owner only (700) — Tor state includes onion keys. */ - private fun restrictToOwner(dir: File) { - try { - Files.setPosixFilePermissions( - dir.toPath(), - setOf( - PosixFilePermission.OWNER_READ, - PosixFilePermission.OWNER_WRITE, - PosixFilePermission.OWNER_EXECUTE, - ), - ) - } catch (e: UnsupportedOperationException) { - // Windows: no POSIX permissions; the user profile's NTFS ACLs apply instead. - } catch (e: Exception) { - Log.w("DesktopTorManager", "Could not restrict permissions on ${dir.absolutePath}", e) - } - } - /** OS-specific data directory for Tor. */ internal fun torDataDirectory(): File { val osName = System.getProperty("os.name", "").lowercase() 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 baf1109759..3439f537dc 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBlobCache.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBlobCache.kt @@ -22,7 +22,6 @@ 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 @@ -56,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") } } } @@ -69,10 +68,10 @@ class NappletBlobCache( dir .listFiles() ?.filter { it.isFile && !it.name.contains(".tmp.") } - ?.map { it to it.length() } ?: return + ?.map { Triple(it, it.length(), it.lastModified()) } ?: return var total = files.sumOf { it.second } if (total <= maxBytes) return - files.sortedBy { it.first.lastModified() }.forEach { (f, length) -> + files.sortedBy { it.third }.forEach { (f, length, _) -> if (total <= maxBytes) return if (f.deleteOrWarn("NappletBlobCache", "blob")) { total -= length From afe8c783c07aeef34e76af2f1b55d725fbdff6b2 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 4 Jul 2026 17:47:53 +0200 Subject: [PATCH 5/5] test(commons): cover deleteOrWarn and restrictToOwner helpers --- .../amethyst/commons/util/FileDeletionTest.kt | 74 +++++++++++++++++ .../commons/util/FilePermissionsTest.kt | 83 +++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/util/FileDeletionTest.kt create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/util/FilePermissionsTest.kt 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") + } +}