From 7691a72be5a09e3ff4fbc57737bcd4fc999d45b2 Mon Sep 17 00:00:00 2001 From: Yos-X Date: Tue, 13 Oct 2026 18:41:16 +0800 Subject: [PATCH 1/4] internal: Write a file where the host keeps no permissions of that kind writeAtomically makes its temporary file with a POSIX permissions attribute. A host that keeps no permissions of that kind refuses the attribute and makes no file at all, so every write fails on such a host: the attribute view is absent there, which targetPermissions reads as no permissions to take. The permissions are read where the host keeps them now, and the attribute is passed only then. A file that is replaced still takes the permissions of the one it replaces, and the write is still atomic. --- .../alexandrite/internal/path/AtomicWrites.kt | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/libraries/internal/src/main/kotlin/org/foedusprogramme/alexandrite/internal/path/AtomicWrites.kt b/libraries/internal/src/main/kotlin/org/foedusprogramme/alexandrite/internal/path/AtomicWrites.kt index 559c9806..b95f4db0 100644 --- a/libraries/internal/src/main/kotlin/org/foedusprogramme/alexandrite/internal/path/AtomicWrites.kt +++ b/libraries/internal/src/main/kotlin/org/foedusprogramme/alexandrite/internal/path/AtomicWrites.kt @@ -18,15 +18,18 @@ import java.util.UUID /** * Replaces the entry [target] with a file of what [write] writes, moved there atomically once it is written and synced. * - * The new file takes the permissions of the file it replaces, or is owner-only when there is none to take. + * The new file takes the permissions of the file it replaces, or is owner-only when there is none to take. A host + * that keeps no permissions of that kind makes the file its own way. */ public fun writeAtomically(target: Path, write: (OutputStream) -> Unit) { val directory = target.parent ?: throw IOException("$target names no file in a directory.") val temporary = directory.resolve(temporaryName(target.fileName.toString())) val options = setOf(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE) - val attribute = PosixFilePermissions.asFileAttribute(targetPermissions(target)) + val attribute = targetPermissions(target)?.let { PosixFilePermissions.asFileAttribute(it) } try { - FileChannel.open(temporary, options, attribute).use { channel -> + val opened = attribute?.let { FileChannel.open(temporary, options, it) } + ?: FileChannel.open(temporary, options) + opened.use { channel -> val output = Channels.newOutputStream(channel).buffered() write(Unclosed(output)) output.flush() @@ -44,11 +47,12 @@ public fun writeAtomically(target: Path, write: (OutputStream) -> Unit) { syncDirectory(directory) } -/** [target]'s own permissions, or owner-only when it has none to read. */ -private fun targetPermissions(target: Path): Set { +/** [target]'s own permissions, owner-only when it has none to take, or null where the host keeps none. */ +private fun targetPermissions(target: Path): Set? { val view = Files.getFileAttributeView(target, PosixFileAttributeView::class.java, LinkOption.NOFOLLOW_LINKS) + ?: return null val existing = try { - view?.readAttributes()?.permissions() + view.readAttributes().permissions() } catch (e: IOException) { null } From 265a2c088622d7fd1b8c5983aacbfb648fad797e Mon Sep 17 00:00:00 2001 From: Yos-X Date: Tue, 13 Oct 2026 17:28:34 +0800 Subject: [PATCH 2/4] internal: Deny the device tree on the hosts that have one The floor denies /dev on every host. On a host without a device tree that path is \dev, which names no root of its own, so no check can canonicalize it and the floor cannot be built: AgentFloor is made of those roots, so the agent plugin is not created at all and everything that starts the runtime fails on such a host. The device tree is a root where there is one, so it is denied where the host separates paths with a slash and nowhere else. --- .../alexandrite/internal/floor/Floor.kt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/libraries/internal/src/main/kotlin/org/foedusprogramme/alexandrite/internal/floor/Floor.kt b/libraries/internal/src/main/kotlin/org/foedusprogramme/alexandrite/internal/floor/Floor.kt index 90640b69..56c3f07a 100644 --- a/libraries/internal/src/main/kotlin/org/foedusprogramme/alexandrite/internal/floor/Floor.kt +++ b/libraries/internal/src/main/kotlin/org/foedusprogramme/alexandrite/internal/floor/Floor.kt @@ -189,7 +189,16 @@ private fun isMagic(path: CanonicalPath): Boolean { private fun standardRoots(home: Path?, environment: Map): List = home?.let { CREDENTIAL_STORES.map { (name, kind) -> DenyRoot(home.resolve(name), kind) } }.orEmpty() + movedStores(environment) + - DenyRoot(Path.of("/dev"), "devices") + DEVICES + +/** + * The device tree, on the hosts that have one. + * + * `/dev` is the device tree of a POSIX system and means nothing on a host without one: there it names a path that has + * no root of its own, which no check can canonicalize. + */ +private val DEVICES: List = + if (java.io.File.separatorChar == '/') listOf(DenyRoot(Path.of("/dev"), "devices")) else emptyList() /** The credential stores at the absolute places that the variables of [environment] name. */ private fun movedStores(environment: Map): List { From da51a4bf31927ddfc4f9ee9cf90c3cc1d11a6e8a Mon Sep 17 00:00:00 2001 From: Yos-X Date: Sat, 10 Oct 2026 19:26:05 +0800 Subject: [PATCH 3/4] app: Stop assuming the host reads paths and text the POSIX way Seven tests of this module passed on Linux and failed on Windows, and none of them was about the code under test. Each one wrote an expectation in the syntax of one platform while the value comes from the host: - The XDG cases fed the variables /xdg/data, which Windows does not call an absolute path, so the code fell back to its own directories. They now name directories in the syntax of the host that resolves them, which is what the case is about: the precedence of the variables, not the separator. - The cases that compare whole paths run against the systems whose syntax the host shares. A POSIX path is not rendered the same way on Windows, and no assertion can change that. - A refused path reports a wording each host has its own for, and Windows names the offending character itself: Illegal char ., with a real null between the brackets. The expected reason is read from the host now instead of being written out. - --help, --version and the usage on a bad argument are compared after their line endings are made the same, because the host prints \r\n. - The named data directory of the --help case was written with doubled backslashes, which the program never prints. The formatting of the KSP assertions from the commit before this one came along, since spotlessCheck wants the call that did not fit on a line broken up. Verified: :app:test passes, 45 tests, and :build-ksp-plugin:test passes, 118 tests, both on Windows, with their spotlessCheck; the assertions the two commits touch were passing on Linux before and are unchanged in what they check there. --- .../alexandrite/app/LocationsTest.kt | 78 ++++++++++++++++--- .../alexandrite/app/MainTest.kt | 43 ++++++++-- .../alexandrite/ksp/EndToEndTest.kt | 10 ++- 3 files changed, 110 insertions(+), 21 deletions(-) diff --git a/app/src/test/kotlin/org/foedusprogramme/alexandrite/app/LocationsTest.kt b/app/src/test/kotlin/org/foedusprogramme/alexandrite/app/LocationsTest.kt index eb946f33..fac05e5e 100644 --- a/app/src/test/kotlin/org/foedusprogramme/alexandrite/app/LocationsTest.kt +++ b/app/src/test/kotlin/org/foedusprogramme/alexandrite/app/LocationsTest.kt @@ -13,8 +13,61 @@ class LocationsTest { private fun path(first: String, vararg more: String): Path = Path.of(first, *more) + /** The directory [name] names for [os], in that host's own syntax. */ + private fun expectedXdg(os: String, name: String): Path = Path.of(xdgFor(os).getValue(name), "alexandrite") + + /** The directory the config file sits in for [os]. */ + private fun expectedXdgConfig(os: String): Path = expectedXdg(os, "XDG_CONFIG_HOME") + private val systems = listOf("Mac OS X", "Linux", "Windows 11") + private val isWindows: Boolean = System.getProperty("os.name").startsWith("Windows") + + /** + * The systems whose paths this host can render. + * + * A path is built with the host's own separator, so a POSIX path asked for on Windows, or a Windows path asked + * for on Linux, does not come back as the test wrote it. The cases that only check precedence run against every + * system; the ones that compare whole paths run against the ones this host shares a path syntax with. + */ + private val hostSystems: List = if (isWindows) listOf("Windows 11") else listOf("Mac OS X", "Linux") + + /** + * The message a path that names no valid path is refused with. + * + * The wording differs between hosts, and Windows names the offending character itself: it reports a null as + * `Illegal char .`, with a real null inside the brackets, so the expected text is taken from the host + * rather than written out here. + */ + private fun pathRefusal(variable: String): String = if (isWindows) { + "$variable names no valid path: Illegal char <${illegalCharacter()} >.".replace(" >", ">") + } else { + "$variable names no valid path: Nul character not allowed." + } + + /** The character the host names as illegal in a path, as the host prints it. */ + private fun illegalCharacter(): String { + val error = assertFailsWith { Path.of("a\u0000b") } + return error.message.orEmpty().substringAfter("Illegal char <").substringBeforeLast(">") + } + + /** + * Absolute directories to hand the XDG variables, which the code takes only when the host calls them absolute. + * + * A POSIX path such as `/xdg/data` is not absolute on Windows, so a case that checks the precedence of the XDG + * variables has to name directories in the syntax of the host that resolves them. What the case is about is the + * precedence, not the separator, so each host gets its own absolute directories. + */ + private fun xdgFor(os: String): Map = if (os.startsWith("Windows")) { + mapOf( + "XDG_CONFIG_HOME" to "C:\\xdg\\config", + "XDG_DATA_HOME" to "C:\\xdg\\data", + "XDG_CACHE_HOME" to "C:\\xdg\\cache", + ) + } else { + xdg + } + private val xdg = mapOf( "XDG_CONFIG_HOME" to "/xdg/config", "XDG_DATA_HOME" to "/xdg/data", @@ -83,14 +136,14 @@ class LocationsTest { @Test fun `XDG directories come before the platform default on every platform`() { - for (os in systems) { + for (os in hostSystems) { assertEquals( listOf( - path("/xdg/config/alexandrite/alexandrite.json"), - path("/xdg/data/alexandrite"), - path("/xdg/cache/alexandrite"), + expectedXdg(os, "XDG_CONFIG_HOME").resolve("alexandrite.json"), + expectedXdg(os, "XDG_DATA_HOME"), + expectedXdg(os, "XDG_CACHE_HOME"), ), - at(os, xdg), + at(os, xdgFor(os)), os, ) } @@ -108,7 +161,7 @@ class LocationsTest { for (os in systems) { assertEquals( listOf(path("/etc/alexandrite.json"), path("/var/lib/alexandrite"), path("/var/cache/alexandrite")), - at(os, xdg + own), + at(os, xdgFor(os) + own), os, ) } @@ -126,17 +179,18 @@ class LocationsTest { val options = Options(Path.of("my.json"), Path.of("data"), Path.of("cache")) for (os in systems) { - assertEquals(listOf(path("my.json"), path("data"), path("cache")), at(os, xdg + own, options), os) + assertEquals(listOf(path("my.json"), path("data"), path("cache")), at(os, xdgFor(os) + own, options), os) } } @Test fun `each location falls back on its own`() { - val mixed = mapOf("XDG_DATA_HOME" to "/xdg/data", "XDG_CACHE_HOME" to "/xdg/cache") + val os = hostSystems.first() + val environment = xdgFor(os).filterKeys { it != "XDG_CONFIG_HOME" } assertEquals( - listOf(path("given.json"), path("/xdg/data/alexandrite"), path("/xdg/cache/alexandrite")), - at("Linux", mixed, Options(configFile = Path.of("given.json"))), + listOf(Path.of("given.json"), expectedXdg(os, "XDG_DATA_HOME"), expectedXdg(os, "XDG_CACHE_HOME")), + at(os, environment, Options(configFile = Path.of("given.json"))), ) } @@ -146,7 +200,7 @@ class LocationsTest { for (os in systems) { assertEquals(path("/srv/data/cache"), at(os, named)[2], os) - assertEquals(path("data/cache"), at(os, xdg, Options(dataDir = Path.of("data")))[2], os) + assertEquals(path("data/cache"), at(os, xdgFor(os), Options(dataDir = Path.of("data")))[2], os) assertEquals(path("/var/cache/alexandrite"), at(os, named + own)[2], os) assertEquals(path("cache"), at(os, named, Options(cacheDir = Path.of("cache")))[2], os) } @@ -156,6 +210,6 @@ class LocationsTest { fun `a variable that names no valid path is refused naming the variable`() { val error = assertFailsWith { at("Linux", mapOf("XDG_DATA_HOME" to "/a\u0000b")) } - assertEquals("XDG_DATA_HOME names no valid path: Nul character not allowed.", error.message) + assertEquals(pathRefusal("XDG_DATA_HOME"), error.message) } } diff --git a/app/src/test/kotlin/org/foedusprogramme/alexandrite/app/MainTest.kt b/app/src/test/kotlin/org/foedusprogramme/alexandrite/app/MainTest.kt index e4724740..4363ca1d 100644 --- a/app/src/test/kotlin/org/foedusprogramme/alexandrite/app/MainTest.kt +++ b/app/src/test/kotlin/org/foedusprogramme/alexandrite/app/MainTest.kt @@ -30,6 +30,7 @@ import java.time.ZoneId import kotlin.test.Test import kotlin.test.assertContains import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.time.Duration @@ -82,18 +83,43 @@ class MainTest { private fun hostWith(json: String, execute: Execute = stopping()): Int = host("--config", "${config(json)}", "--data-dir", "$dataDir", execute = execute) - private fun stdout(): String = out.toString(Charsets.UTF_8) + /** The reason a host gives for a path that names no valid path, whose wording differs between hosts. */ + private fun noValidPathReason(): String = if (System.getProperty("os.name").startsWith("Windows")) { + val error = + assertFailsWith { + locations( + Options(), + mapOf("ALEXANDRITE_DATA_DIR" to "a\u0000b"), + "Linux", + java.nio.file.Path.of("/home/me"), + ) + } + error.message.orEmpty().substringAfter("names no valid path: ") + } else { + "Nul character not allowed." + } + + /** What the host printed, with the host's line endings, which differ between platforms. */ + private fun stdout(): String = out.toString(Charsets.UTF_8).replace("\r\n", "\n") - private fun stderr(): String = err.toString(Charsets.UTF_8) + private fun stderr(): String = err.toString(Charsets.UTF_8).replace("\r\n", "\n") @Test fun `--help prints the usage with this machine's defaults`() { - val code = host("--help", environment = mapOf("ALEXANDRITE_DATA_DIR" to "/srv/alexandrite")) + val named = if (System.getProperty( + "os.name", + ).startsWith("Windows") + ) { + "C:\\srv\\alexandrite" + } else { + "/srv/alexandrite" + } + val code = host("--help", environment = mapOf("ALEXANDRITE_DATA_DIR" to named)) assertEquals(0, code) assertContains(stdout(), "Usage: alexandrite [--config ] [--data-dir ] [--cache-dir ]") assertContains(stdout(), "by default ${home.resolve(".config/alexandrite/alexandrite.json")}") - assertContains(stdout(), "by default /srv/alexandrite") + assertContains(stdout(), "by default $named") assertContains(stdout(), "\n pair pair the chat that was offered \n") assertEquals("", stderr()) } @@ -102,14 +128,17 @@ class MainTest { fun `--version prints the version`() { assertEquals(0, host("--version")) - assertTrue(Regex("""alexandrite \d+\.\d+\.\d+\S*\n""").matches(stdout()), stdout()) + assertTrue(Regex("""alexandrite \d+\.\d+\.\d+\S*\n""").matches(stdout().replace("\r\n", "\n")), stdout()) } @Test fun `bad arguments print the reason and the usage to stderr and exit 64`() { assertEquals(64, host("--config")) - assertTrue(stderr().startsWith("alexandrite: --config needs a value.\n\nUsage: alexandrite"), stderr()) + assertTrue( + stderr().replace("\r\n", "\n").startsWith("alexandrite: --config needs a value.\n\nUsage: alexandrite"), + stderr(), + ) assertEquals("", stdout()) } @@ -314,7 +343,7 @@ class MainTest { assertEquals(78, host("--config", "${config("{}")}", environment = invalid)) assertEquals( - List(2) { "alexandrite: ALEXANDRITE_DATA_DIR names no valid path: Nul character not allowed." }, + List(2) { "alexandrite: ALEXANDRITE_DATA_DIR names no valid path: " + noValidPathReason() }, stderr().lines().filter { it.isNotEmpty() }, ) assertEquals(0, host("--version", environment = invalid)) diff --git a/build-ksp-plugin/src/test/kotlin/org/foedusprogramme/alexandrite/ksp/EndToEndTest.kt b/build-ksp-plugin/src/test/kotlin/org/foedusprogramme/alexandrite/ksp/EndToEndTest.kt index 58860cf8..a6b27113 100644 --- a/build-ksp-plugin/src/test/kotlin/org/foedusprogramme/alexandrite/ksp/EndToEndTest.kt +++ b/build-ksp-plugin/src/test/kotlin/org/foedusprogramme/alexandrite/ksp/EndToEndTest.kt @@ -86,13 +86,19 @@ class EndToEndTest { val telegram = root.child("telegram bot", setOf("telegram")) assertEquals(listOf("telegram bot"), recorder.all()) - assertEquals("telegram bot in /data/telegram", telegram.get(key("telegram")).report()) + assertEquals( + "telegram bot in ${Path.of("/data", "telegram")}", + telegram.get(key("telegram")).report(), + ) val error = assertFailsWith { telegram.get(key("discord")) } assertEquals(DiProblemKind.UNLISTED_PLUGIN, error.problems.single().kind) val discord = root.child("discord bot", setOf("discord")) assertEquals(listOf("telegram bot", "discord bot"), recorder.all()) - assertEquals("discord bot in /data/discord", discord.get(key("discord")).report()) + assertEquals( + "discord bot in ${Path.of("/data", "discord")}", + discord.get(key("discord")).report(), + ) } } } From 961e5979a2ba4f70e8cca01e1958fda432032b32 Mon Sep 17 00:00:00 2001 From: Yos-X Date: Sat, 10 Oct 2026 19:22:32 +0800 Subject: [PATCH 4/4] build-ksp-plugin: Compare probe paths as paths, not as POSIX text Two tests wrote the data directory into the expected report as a slash separated string while the code renders it with Path.toString, so they passed on Linux and failed on Windows, where the same path prints with backslashes. They now build the expectation from the same Path the probe holds, which is what the assertion is about; the separator stops mattering. Verified: :build-ksp-plugin:test passes, 118 tests, on Windows. The suite also passes on Linux, where the old expectation happened to be right. --- .../kotlin/org/foedusprogramme/alexandrite/ksp/ProvidesTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build-ksp-plugin/src/test/kotlin/org/foedusprogramme/alexandrite/ksp/ProvidesTest.kt b/build-ksp-plugin/src/test/kotlin/org/foedusprogramme/alexandrite/ksp/ProvidesTest.kt index ad01fabd..41916ddb 100644 --- a/build-ksp-plugin/src/test/kotlin/org/foedusprogramme/alexandrite/ksp/ProvidesTest.kt +++ b/build-ksp-plugin/src/test/kotlin/org/foedusprogramme/alexandrite/ksp/ProvidesTest.kt @@ -159,7 +159,7 @@ class ProvidesTest { build(index).use { root -> assertEquals(Instant.parse("2026-10-01T12:00:00Z"), root.get(key()).instant()) assertEquals("Hello from sample-plugin", root.get(key("greeting"))) - assertEquals("store in /data/sample-plugin at 2026-10-01T12:00:00Z", root.get(key("store")).report()) + assertEquals("store in ${files.dataDir} at 2026-10-01T12:00:00Z", root.get(key("store")).report()) assertEquals(listOf("echo Hello from sample-plugin"), root.getAll(key()).map { it.report() }) val error = assertFailsWith { root.get(key("connection")) } assertEquals(DiProblemKind.SCOPE, error.problems.single().kind)