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(), + ) } } } 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) 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 { 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 }