Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = 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 <NUL>.`, 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<IllegalArgumentException> { 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<String, String> = 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",
Expand Down Expand Up @@ -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,
)
}
Expand All @@ -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,
)
}
Expand All @@ -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"))),
)
}

Expand All @@ -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)
}
Expand All @@ -156,6 +210,6 @@ class LocationsTest {
fun `a variable that names no valid path is refused naming the variable`() {
val error = assertFailsWith<IllegalArgumentException> { 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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<IllegalArgumentException> {
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 <file>] [--data-dir <dir>] [--cache-dir <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 <code> pair the chat that was offered <code>\n")
assertEquals("", stderr())
}
Expand All @@ -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())
}

Expand Down Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Probe>("telegram")).report())
assertEquals(
"telegram bot in ${Path.of("/data", "telegram")}",
telegram.get(key<Probe>("telegram")).report(),
)
val error = assertFailsWith<DiException> { telegram.get(key<Probe>("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<Probe>("discord")).report())
assertEquals(
"discord bot in ${Path.of("/data", "discord")}",
discord.get(key<Probe>("discord")).report(),
)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ class ProvidesTest {
build(index).use { root ->
assertEquals(Instant.parse("2026-10-01T12:00:00Z"), root.get(key<Clock>()).instant())
assertEquals("Hello from sample-plugin", root.get(key<String>("greeting")))
assertEquals("store in /data/sample-plugin at 2026-10-01T12:00:00Z", root.get(key<Probe>("store")).report())
assertEquals("store in ${files.dataDir} at 2026-10-01T12:00:00Z", root.get(key<Probe>("store")).report())
assertEquals(listOf("echo Hello from sample-plugin"), root.getAll(key<Probe>()).map { it.report() })
val error = assertFailsWith<DiException> { root.get(key<Probe>("connection")) }
assertEquals(DiProblemKind.SCOPE, error.problems.single().kind)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,16 @@ private fun isMagic(path: CanonicalPath): Boolean {
private fun standardRoots(home: Path?, environment: Map<String, String>): List<DenyRoot> =
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<DenyRoot> =
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<String, String>): List<DenyRoot> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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<PosixFilePermission> {
/** [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<PosixFilePermission>? {
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
}
Expand Down
Loading