Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,42 @@ 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")

/**
* 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 @@ -90,14 +124,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 @@ -115,7 +149,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 @@ -133,17 +167,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 @@ -153,7 +188,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 Down
36 changes: 31 additions & 5 deletions app/src/test/kotlin/org/unitedsoftware/alexandrite/app/MainTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,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."
}

private fun stderr(): String = err.toString(Charsets.UTF_8)
/** 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).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 Down Expand Up @@ -355,7 +381,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 @@ -237,7 +237,9 @@ private fun standardRoots(
home?.let { addAll(CREDENTIAL_STORES.map { (name, kind) -> DenyRoot(home.resolve(name), kind) }) }
addAll(movedStores(environment))
}
if (StandardPlaces.SYSTEM in standard) add(DenyRoot(Path.of("/dev"), "devices"))
// The device tree is a root where the host has one: on a host whose paths the host separates otherwise, `/dev`
// names no root of its own, so no check can canonicalize it and the floor cannot be built of it.
if (StandardPlaces.SYSTEM in standard && File.separatorChar == '/') add(DenyRoot(Path.of("/dev"), "devices"))
}

/** The credential stores at the absolute places that the variables of [environment] name. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,9 @@ public class CanonicalPath internal constructor(
internal fun keys(ignoreCase: Boolean, forms: Collection<PathForm> = PathForm.entries): List<String> =
forms.map { unalias(nameKey(path(it).toString(), ignoreCase)) }.distinct()

/** [form] as it names an entry: verbatim, or case-folded when [ignoreCase]. */
internal fun entryKey(form: PathForm, ignoreCase: Boolean): String {
val text = path(form).toString()
return unalias(if (ignoreCase) nameKey(text, true) else text)
}
/** [form] as it names an entry: verbatim, or case-folded when [ignoreCase], with the keys of [nameKey]. */
internal fun entryKey(form: PathForm, ignoreCase: Boolean): String =
unalias(nameKey(path(form).toString(), ignoreCase))

private fun path(form: PathForm): Path = when (form) {
PathForm.LEXICAL -> lexical
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,33 @@
package org.unitedsoftware.alexandrite.internal.path

import java.io.File
import java.text.Normalizer
import java.util.Locale

/** The macOS volume that every firmlink points into. */
internal const val DATA_VOLUME: String = "/System/Volumes/Data"

/**
* [path] in NFC, and fully case-folded when [ignoreCase], as APFS compares names.
* [path] in NFC, with the separators of a Windows host as `/`, and fully case-folded when [ignoreCase], as APFS
* compares names.
*
* A key compares names, not spellings, so a host whose paths use `\` gives the same keys as one whose paths use `/`:
* a key then ends up under another key's `/` on either host. A `\` stays a name character wherever the host allows it
* in a name.
*
* Uppercasing first folds what lowercasing alone keeps (`ſ`, the Kelvin sign), and folding can decompose, so the result
* is NFC again.
*/
internal fun nameKey(path: String, ignoreCase: Boolean): String {
val nfc = Normalizer.normalize(path, Normalizer.Form.NFC)
val unified = if (WINDOWS_SEPARATOR) path.replace('\\', '/') else path
val nfc = Normalizer.normalize(unified, Normalizer.Form.NFC)
if (!ignoreCase) return nfc
return Normalizer.normalize(nfc.uppercase(Locale.ROOT).lowercase(Locale.ROOT), Normalizer.Form.NFC)
}

/** Whether this host separates the elements of a path with `\`. */
private val WINDOWS_SEPARATOR: Boolean = File.separatorChar == '\\'

/** [path] with every leading [DATA_VOLUME] folded back to `/`, ignoring case. */
internal fun foldDataVolume(path: String): String {
var current = path
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package org.unitedsoftware.alexandrite.internal.floor

import org.junit.jupiter.api.condition.EnabledOnOs
import org.junit.jupiter.api.condition.OS
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Files
import java.nio.file.Path
import kotlin.test.Test
import kotlin.test.assertIs

/** What the floor refuses on a host whose paths are separated with `\`. */
@EnabledOnOs(OS.WINDOWS)
class FloorWindowsTest {
@TempDir
lateinit var directory: Path

@Test
fun `a path under a Windows deny root is denied, and a place beside it is allowed`() {
val root = directory.toRealPath()
val secret = root.resolve("secret")
val key = secret.resolve("key.txt")
Files.createDirectories(secret)
Files.writeString(key, "SECRET")
val home = root.resolve("home")
Files.createDirectories(home)
val floor = Floor(listOf(DenyRoot(secret, "test secret")), home)

assertIs<FloorDecision.Denied>(floor.check(key))
assertIs<FloorDecision.Denied>(floor.checkTree(secret))
assertIs<FloorDecision.Allowed>(floor.check(root.resolve("notes.txt")))
}

@Test
fun `a Windows path in another case, or with a forward slash, is the same place`() {
val root = directory.toRealPath()
val secret = root.resolve("secret")
val key = secret.resolve("key.txt")
Files.createDirectories(secret)
Files.writeString(key, "SECRET")
val home = root.resolve("home")
Files.createDirectories(home)
val floor = Floor(listOf(DenyRoot(secret, "test secret")), home)

assertIs<FloorDecision.Denied>(floor.check(Path.of(key.toString().replace("secret", "SECRET"))))
assertIs<FloorDecision.Denied>(floor.check(Path.of(key.toString().replace('\\', '/'))))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ internal class Chain(val steps: List<ChainStep>, val errexit: Boolean)
* the wildcards `*` and `?`.
*/
internal fun shellChain(argv: List<String>, home: String?): Chain? {
val program = argv.first().substringAfterLast('/')
val program = baseName(argv.first())
val (shell, arguments) = when {
program in POSIX_SHELLS -> program to argv.drop(1)
program == "busybox" && argv.getOrNull(1) == "sh" -> "sh" to argv.drop(2)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ internal data class ParsedOption(val name: String, val value: String?)
internal object CommandOptions {
/** [argv] parsed by its program's option table, or by none unless [withTable]. */
fun parse(argv: List<String>, withTable: Boolean = true): ParsedCommand {
val program = argv.firstOrNull()?.substringAfterLast('/').orEmpty()
val program = argv.firstOrNull()?.let(::baseName).orEmpty()
val table = if (withTable) TABLES[program] else null
val options = mutableListOf<ParsedOption>()
val operands = mutableListOf<String>()
Expand Down
Loading
Loading