From 08169f8497690818c8817f221d1471f353ea8bc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 21:12:49 +0000 Subject: [PATCH] refactor(commons): move the napplet wire protocol and scheduled posts to commonMain Tier 4 of the commons migration sweep: two jvmAndroid groups that were each pinned by a single JVM API. Napplet: NappletProtocolJson (and NappletIdentityWatch, NappletRequestRouter, which only waited on it) used java.util.Base64 and System.currentTimeMillis. Now kotlin.io.encoding.Base64 and TimeUtils.now(). The decoder uses PaddingOption.PRESENT_OPTIONAL because java.util.Base64.getDecoder() accepted unpadded input; a test pins that. Scheduled posts: ScheduledPostStore was pinned by java.io.File and a POSIX chmod. It now takes an okio Path + FileSystem (plus a String constructor, the FileSystemNip95BlobStore shape, so callers need no okio). Owner-only permissions are a new expect fun restrictFileToOwner: Files.setPosix... on jvmAndroid, chmod(0600) on iOS. The temp-file swap is FileSystem.atomicMove, which replaces an existing target everywhere, so the rename/delete/rename fallback is gone. The work gate, publisher and notifier came along. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UjQQN9CgWHVtNCSWqnKoqY --- .../com/vitorpamplona/amethyst/AppModules.kt | 2 +- .../napplet/NappletProtocolJsonTest.kt | 7 ++ .../ScheduledPostWorkGateTest.kt | 2 +- .../commons/napplet/NappletIdentityWatch.kt | 0 .../commons/napplet/NappletRequestRouter.kt | 0 .../napplet/protocol/NappletProtocolJson.kt | 20 +++-- .../scheduledposts/ScheduledPostNotifier.kt | 0 .../scheduledposts/ScheduledPostPublisher.kt | 0 .../scheduledposts/ScheduledPostStore.kt | 78 +++++++++---------- .../scheduledposts/ScheduledPostWorkGate.kt | 0 .../amethyst/commons/util/OwnerOnlyFile.kt | 34 ++++++++ .../commons/util/OwnerOnlyFile.ios.kt | 40 ++++++++++ .../commons/util/OwnerOnlyFile.jvmAndroid.kt | 42 ++++++++++ .../scheduledposts/ScheduledPostStoreTest.kt | 2 +- .../DesktopScheduledPostStore.kt | 2 +- .../HeadlessScheduledPublish.kt | 2 +- 16 files changed, 175 insertions(+), 56 deletions(-) rename commons/src/{jvmAndroid => commonMain}/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletIdentityWatch.kt (100%) rename commons/src/{jvmAndroid => commonMain}/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouter.kt (100%) rename commons/src/{jvmAndroid => commonMain}/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt (97%) rename commons/src/{jvmAndroid => commonMain}/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostNotifier.kt (100%) rename commons/src/{jvmAndroid => commonMain}/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostPublisher.kt (100%) rename commons/src/{jvmAndroid => commonMain}/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStore.kt (88%) rename commons/src/{jvmAndroid => commonMain}/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostWorkGate.kt (100%) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/OwnerOnlyFile.kt create mode 100644 commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/OwnerOnlyFile.ios.kt create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/OwnerOnlyFile.jvmAndroid.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 3e0c5f6a6e..934226f2c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -1154,7 +1154,7 @@ class AppModules( // Local store for posts the user has scheduled to publish later. Backed by a // single JSON file under the app's private filesDir; read by ScheduledPostWorker. val scheduledPostStore = - ScheduledPostStore(File(appContext.filesDir, ScheduledPostStore.FILE_NAME)) + ScheduledPostStore(File(appContext.filesDir, ScheduledPostStore.FILE_NAME).path) // Organizes cache clearing val trimmingService by diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt index 1f2422de4b..93399e85a2 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt @@ -184,6 +184,13 @@ class NappletProtocolJsonTest { assertEquals("Hi", up.bytes.decodeToString()) } + @Test + fun uploadAcceptsUnpaddedBase64() { + // java.util.Base64 decoded "SGk" (no '=') as "Hi"; the commonMain decoder must too. + val up = NappletProtocolJson.decodeRequest("""{"type":"upload.upload","request":{"dataBase64":"SGk"}}""") as NappletRequest.UploadBlob + assertEquals("Hi", up.bytes.decodeToString()) + } + @Test fun unknownTypeDecodesToNull() { assertNull(NappletProtocolJson.decodeRequest("""{"type":"inc.emit","id":"1"}""")) diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGateTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGateTest.kt index e346274630..d3028575ea 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGateTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGateTest.kt @@ -57,7 +57,7 @@ class ScheduledPostWorkGateTest { decisions.clear() } - private fun newStore() = ScheduledPostStore(file) + private fun newStore() = ScheduledPostStore(file.path) private fun TestScope.startGate(store: ScheduledPostStore) = ScheduledPostWorkGate( diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletIdentityWatch.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletIdentityWatch.kt similarity index 100% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletIdentityWatch.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletIdentityWatch.kt diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouter.kt similarity index 100% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouter.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouter.kt diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt similarity index 97% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt index 027cae99df..af171f6188 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.napplet.protocol import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject @@ -37,7 +38,7 @@ import kotlinx.serialization.json.long import kotlinx.serialization.json.put import kotlinx.serialization.json.putJsonArray import kotlinx.serialization.json.putJsonObject -import java.util.Base64 +import kotlin.io.encoding.Base64 /** * Marshals the KMP-pure [NappletRequest] / [NappletResponse] types to and from the wire the @@ -45,10 +46,10 @@ import java.util.Base64 * (`@napplet/shim` / `@napplet/nap`): requests are `{ "type": ".", "id", ...fields }` * and replies are `{ "type": "..result", "id", ...fields }`. * - * Lives in `commons/jvmAndroid` (not in any single front end) because the wire contract is identical + * Lives in `commons/commonMain` (not in any single front end) because the wire contract is identical * across hosts: the Android `:napplet` WebView host and a future desktop host both marshal through - * here. It depends only on `quartz` (Event/Filter), kotlinx.serialization, and `java.util.Base64` - * (available on Android API 26+ and the JVM) — no platform-UI or process APIs. + * here. It depends only on `quartz` (Event/Filter), kotlinx.serialization and `kotlin.io.encoding` + * — no platform-UI or process APIs. * * This is the only place the boundary parses untrusted applet input, so it is deliberately * strict: an unrecognized `type` decodes to `null` (the broker denies it) and a malformed/short @@ -58,6 +59,9 @@ import java.util.Base64 object NappletProtocolJson { private val json = Json { ignoreUnknownKeys = true } + // Accepts unpadded input, as java.util.Base64's decoder did before this moved to commonMain. + private val lenientBase64 = Base64.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL) + /** The `type` discriminant of a request envelope, used to build the matching `.result` type. */ fun readType(envelopeJson: String): String? = json.parseToJsonElement(envelopeJson).jsonObject.str("type") @@ -166,7 +170,7 @@ object NappletProtocolJson { kind = t.kindOf(), tags = decodeTags(t), content = t.str("content") ?: "", - createdAt = t["created_at"]?.jsonPrimitive?.long ?: (System.currentTimeMillis() / 1000), + createdAt = t["created_at"]?.jsonPrimitive?.long ?: TimeUtils.now(), ) } // NIP-07 nip44.encrypt/decrypt: crypto only, no publish. `peer` is the counterparty @@ -194,7 +198,7 @@ object NappletProtocolJson { // The Blob in `request.data` is inlined as base64 `request.dataBase64` by shell.html. val request = o.getValue("request").jsonObject NappletRequest.UploadBlob( - bytes = Base64.getDecoder().decode(request.req("dataBase64")), + bytes = lenientBase64.decode(request.req("dataBase64")), contentType = request.str("mimeType") ?: "application/octet-stream", filename = request.str("filename"), ) @@ -275,7 +279,7 @@ object NappletProtocolJson { } is NappletResponse.Bytes -> { put("ok", true) - put("bytes", Base64.getEncoder().encodeToString(response.bytes)) + put("bytes", Base64.encode(response.bytes)) put("mime", response.contentType) } is NappletResponse.ResourceInfo -> { @@ -301,7 +305,7 @@ object NappletProtocolJson { put("url", item.url) put("ok", item.resource != null) item.resource?.let { - put("bytes", Base64.getEncoder().encodeToString(it.bytes)) + put("bytes", Base64.encode(it.bytes)) put("mime", it.contentType) } item.error?.let { put("error", it) } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostNotifier.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostNotifier.kt similarity index 100% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostNotifier.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostNotifier.kt diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostPublisher.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostPublisher.kt similarity index 100% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostPublisher.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostPublisher.kt diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStore.kt similarity index 88% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStore.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStore.kt index d19e8e7a6c..ec34e46725 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStore.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStore.kt @@ -20,21 +20,31 @@ */ package com.vitorpamplona.amethyst.commons.scheduledposts +import com.vitorpamplona.amethyst.commons.util.platformFileSystem +import com.vitorpamplona.amethyst.commons.util.restrictFileToOwner import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.serialization.json.Json -import java.io.File -import java.nio.file.Files -import java.nio.file.attribute.PosixFilePermission +import okio.FileSystem +import okio.Path +import okio.Path.Companion.toPath class ScheduledPostStore( - private val storageFile: File, - private val nowSec: () -> Long = { System.currentTimeMillis() / 1000 }, + private val storageFile: Path, + private val nowSec: () -> Long = { TimeUtils.now() }, + private val fileSystem: FileSystem = platformFileSystem, ) { + /** Takes the platform's own path string, so callers need no okio of their own. */ + constructor( + storagePath: String, + nowSec: () -> Long = { TimeUtils.now() }, + ) : this(storagePath.toPath(), nowSec) + /** * `encodeDefaults = true` so this writes the same bytes Jackson did — kotlinx * omits a value equal to its default, which would silently drop `"version":1` @@ -214,7 +224,7 @@ class ScheduledPostStore( */ suspend fun publishNow( id: String, - nowSec: Long = System.currentTimeMillis() / 1000, + nowSec: Long = TimeUtils.now(), ): Boolean = mutex.withLock { ensureLoaded() @@ -336,8 +346,8 @@ class ScheduledPostStore( private fun reloadFromDiskLocked() { val fromDisk = try { - if (storageFile.exists() && storageFile.length() > 0) { - json.decodeFromString(storageFile.readText()).posts.toMutableList() + if (hasContent()) { + json.decodeFromString(readText()).posts.toMutableList() } else { // File vanished — treat as no external state; keep current in-memory. return @@ -354,8 +364,8 @@ class ScheduledPostStore( if (loaded) return posts = try { - if (storageFile.exists() && storageFile.length() > 0) { - json.decodeFromString(storageFile.readText()).posts.toMutableList() + if (hasContent()) { + json.decodeFromString(readText()).posts.toMutableList() } else { mutableListOf() } @@ -367,7 +377,7 @@ class ScheduledPostStore( // Secure a pre-existing file up front (an older build may have left it at the // 0644 umask default) so it's owner-only even if nothing mutates the store // this session. - if (storageFile.exists()) restrictToOwner(storageFile) + if (fileSystem.exists(storageFile)) restrictFileToOwner(storageFile, TAG) var dirty = purgeStale(nowSec()) // Recover claims stranded by a crash-mid-publish so they aren't lost forever. if (recoverStuckClaimsLocked(nowSec())) dirty = true @@ -412,48 +422,30 @@ class ScheduledPostStore( private fun persist() { val snapshot = posts.toList() _flow.value = snapshot - storageFile.parentFile?.mkdirs() - val tmp = File(storageFile.parentFile, storageFile.name + ".tmp") + val tmp = "$storageFile.tmp".toPath() try { - tmp.writeText(json.encodeToString(ScheduledPostFile(version = 1, posts = snapshot))) + storageFile.parent?.let { fileSystem.createDirectories(it) } + fileSystem.write(tmp) { writeUtf8(json.encodeToString(ScheduledPostFile(version = 1, posts = snapshot))) } // Restrict to owner-only BEFORE the rename so the store is never briefly // world-readable. It holds pre-signed events + the account's pubkey, which // must not leak to other local users on a shared machine. - restrictToOwner(tmp) - if (!tmp.renameTo(storageFile)) { - if (!storageFile.delete()) { - Log.w(TAG) { "Failed to delete existing $storageFile before rename retry" } - } - if (!tmp.renameTo(storageFile)) { - Log.e(TAG) { "Failed to rename $tmp to $storageFile" } - if (!tmp.delete()) { - Log.w(TAG) { "Failed to clean up temp file $tmp after rename failure" } - } - } - } + restrictFileToOwner(tmp, TAG) + // Replaces an existing target, including on Windows, where a plain rename + // onto an existing file fails. + fileSystem.atomicMove(tmp, storageFile) } catch (e: Exception) { Log.e(TAG, "Failed to persist scheduled posts to $storageFile", e) - if (!tmp.delete()) { - Log.w(TAG) { "Failed to clean up temp file $tmp after persist exception" } + try { + fileSystem.delete(tmp) + } catch (e: Exception) { + Log.w(TAG) { "Failed to clean up temp file $tmp after persist exception: ${e.message}" } } } } - /** - * Best-effort chmod to `0600` (owner read/write only). No-op on filesystems - * without POSIX permissions (e.g. Windows), where confidentiality relies on the - * per-user home directory instead. Never throws. - */ - private fun restrictToOwner(file: File) { - try { - Files.setPosixFilePermissions( - file.toPath(), - setOf(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE), - ) - } catch (e: Exception) { - Log.w(TAG) { "Could not restrict permissions on $file: ${e.message}" } - } - } + private fun hasContent(): Boolean = (fileSystem.metadataOrNull(storageFile)?.size ?: 0L) > 0L + + private fun readText(): String = fileSystem.read(storageFile) { readUtf8() } companion object { private const val TAG = "ScheduledPostStore" diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostWorkGate.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostWorkGate.kt similarity index 100% rename from commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostWorkGate.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostWorkGate.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/OwnerOnlyFile.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/OwnerOnlyFile.kt new file mode 100644 index 0000000000..05934c55ca --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/OwnerOnlyFile.kt @@ -0,0 +1,34 @@ +/* + * 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 okio.Path + +/** + * Restricts the file at [path] to owner read/write (`0600`), best-effort. Never throws. + * + * Silent where the filesystem has no POSIX permissions (Windows), where the user profile's + * ACLs apply instead; logs under [tag] when a POSIX filesystem refuses. + */ +expect fun restrictFileToOwner( + path: Path, + tag: String, +) diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/OwnerOnlyFile.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/OwnerOnlyFile.ios.kt new file mode 100644 index 0000000000..42b3c0c516 --- /dev/null +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/OwnerOnlyFile.ios.kt @@ -0,0 +1,40 @@ +/* + * 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 kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.convert +import okio.Path +import platform.posix.S_IRUSR +import platform.posix.S_IWUSR +import platform.posix.chmod +import platform.posix.errno + +@OptIn(ExperimentalForeignApi::class) +actual fun restrictFileToOwner( + path: Path, + tag: String, +) { + if (chmod(path.toString(), (S_IRUSR or S_IWUSR).convert()) != 0) { + Log.w(tag) { "Could not restrict permissions on $path: errno $errno" } + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/OwnerOnlyFile.jvmAndroid.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/OwnerOnlyFile.jvmAndroid.kt new file mode 100644 index 0000000000..5d4d16c3b7 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/OwnerOnlyFile.jvmAndroid.kt @@ -0,0 +1,42 @@ +/* + * 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 okio.Path +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermission + +actual fun restrictFileToOwner( + path: Path, + tag: String, +) { + try { + Files.setPosixFilePermissions( + path.toNioPath(), + setOf(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE), + ) + } 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 $path: ${e.message}" } + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStoreTest.kt index 6033715624..bf0fdb92e2 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStoreTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStoreTest.kt @@ -47,7 +47,7 @@ class ScheduledPostStoreTest { file = File(temp.root, "scheduled_posts.json") } - private fun newStore(now: () -> Long = { System.currentTimeMillis() / 1000 }) = ScheduledPostStore(file, now) + private fun newStore(now: () -> Long = { System.currentTimeMillis() / 1000 }) = ScheduledPostStore(file.path, now) private fun samplePost( id: String = "id-1", diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/DesktopScheduledPostStore.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/DesktopScheduledPostStore.kt index ebd05606b5..96ccc66328 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/DesktopScheduledPostStore.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/DesktopScheduledPostStore.kt @@ -52,7 +52,7 @@ object DesktopScheduledPostStore { if (file.exists()) { setOwnerOnly(file, PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE) } - return ScheduledPostStore(file) + return ScheduledPostStore(file.path) } private fun setDirPermissions(dir: File) = diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/HeadlessScheduledPublish.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/HeadlessScheduledPublish.kt index 9c5fb8ee77..49b554ba66 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/HeadlessScheduledPublish.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/scheduledposts/HeadlessScheduledPublish.kt @@ -82,7 +82,7 @@ fun runHeadlessPublish(): Int { } private fun headlessDrain(log: (String) -> Unit): Int { - val store = ScheduledPostStore(File(scheduledDir(), ScheduledPostStore.FILE_NAME)) + val store = ScheduledPostStore(File(scheduledDir(), ScheduledPostStore.FILE_NAME).path) // Build a plain OkHttp client for websockets — no Tor, no proxy. Headless mode // reads no settings, so we cannot know Tor preferences; scheduled posts that