diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 3133253d..a1a7babe 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -548,6 +548,8 @@ fn default_denied_roots() -> Vec { roots.push(PathBuf::from(static_path)); } + // The user's REAL home, deliberately -- not graph_explorer_dir(). $GX_HOME + // relocates gx's own data; it must not be able to un-deny a secret. if let Some(home) = dirs::home_dir() { roots.push(home.join(".ssh")); roots.push(home.join(".gnupg")); @@ -610,9 +612,49 @@ fn runtime_file_path() -> Result { Ok(runtime_dir_path()?.join("control.json")) } +/// Where Graph Explorer keeps its own state: `library/` and `runtime/`. +/// +/// `$GX_HOME` replaces that directory wholesale. Default: `~/.graph-explorer`. +/// +/// It exists because the two halves could not be pointed at the same place. +/// This side follows `$HOME` (that is what `dirs::home_dir` does); `gx` is a +/// GraalVM native image reading `user.home`, which on macOS comes from the +/// password database and ignores `$HOME`. So a test that launched a desktop +/// under a redirected `$HOME` and then asked `gx` about it was silently asking +/// about the REAL desktop. One variable both sides read closes that. +/// +/// NOT a general home override: the access policy's denied roots (`~/.ssh`, +/// `~/.gnupg`, ...) deliberately keep reading the true home, because relocating +/// gx's own data must never quietly un-deny a secret. +fn graph_explorer_dir() -> Result { + Ok(graph_explorer_dir_from( + env::var_os("GX_HOME"), + dirs::home_dir(), + )?) +} + +/// The decision, separated from the process it reads. Env vars are global and +/// a test that set one would race every other test in the binary; this way the +/// rule is exercised directly. +fn graph_explorer_dir_from( + gx_home: Option, + home_dir: Option, +) -> Result { + if let Some(dir) = gx_home { + let dir = PathBuf::from(dir); + // A set-but-empty variable means "unset" here. Exporting an empty + // GX_HOME is how a shell script passes through an unset value, and + // taking it literally would put the library at the filesystem root. + if !dir.as_os_str().is_empty() { + return Ok(dir); + } + } + let home_dir = home_dir.context("could not locate user home directory")?; + Ok(home_dir.join(".graph-explorer")) +} + fn runtime_dir_path() -> Result { - let home_dir = dirs::home_dir().context("could not locate user home directory")?; - Ok(home_dir.join(".graph-explorer").join("runtime")) + Ok(graph_explorer_dir()?.join("runtime")) } fn control_socket_path() -> Result { @@ -2023,11 +2065,7 @@ fn set_owner_only_permissions(_path: &Path) -> Result<()> { // how V-13's content hash diverged. fn library_dir_path() -> Result { - let home_dir = dirs::home_dir().context("could not locate user home directory")?; - Ok(home_dir - .join(".graph-explorer") - .join("library") - .join("diagrams")) + Ok(graph_explorer_dir()?.join("library").join("diagrams")) } /// Resolve a library file name against the library directory, checking only @@ -2503,6 +2541,53 @@ mod tests { } } + /// `$GX_HOME` has to mean the same directory here and in `gx`, or it is + /// worse than not existing: a library in one place and the runtime file + /// that names its socket in another. The Scala half of this rule lives in + /// gx-core's GxHome, tested there — these are the same cases. + #[test] + fn gx_home_replaces_the_data_directory() { + let dir = graph_explorer_dir_from( + Some(std::ffi::OsString::from("/tmp/scratch")), + Some(PathBuf::from("/Users/someone")), + ) + .expect("an explicit GX_HOME needs no home directory"); + // The variable names the data directory itself. Nesting + // `.graph-explorer` inside it would put a hidden directory in a path + // the caller chose explicitly. + assert_eq!(dir, PathBuf::from("/tmp/scratch")); + } + + #[test] + fn without_gx_home_it_is_the_dot_directory_under_home() { + let dir = graph_explorer_dir_from(None, Some(PathBuf::from("/Users/someone"))) + .expect("a home directory is enough"); + assert_eq!(dir, PathBuf::from("/Users/someone/.graph-explorer")); + } + + #[test] + fn a_blank_gx_home_means_unset_not_the_filesystem_root() { + // `export GX_HOME="$SOMETHING"` with SOMETHING unset is how a shell + // script passes through an absent value. Taken literally it would put + // the library at `/library`. + let dir = graph_explorer_dir_from( + Some(std::ffi::OsString::from("")), + Some(PathBuf::from("/Users/someone")), + ) + .expect("a blank value falls back to the home directory"); + assert_eq!(dir, PathBuf::from("/Users/someone/.graph-explorer")); + } + + #[test] + fn an_explicit_gx_home_works_with_no_home_directory_at_all() { + // The point of the fallback order: a process that cannot locate a home + // is still usable if it was told where to look. + let dir = graph_explorer_dir_from(Some(std::ffi::OsString::from("/tmp/scratch")), None) + .expect("GX_HOME alone is sufficient"); + assert_eq!(dir, PathBuf::from("/tmp/scratch")); + assert!(graph_explorer_dir_from(None, None).is_err(), "neither one is an error"); + } + #[test] fn a_starting_desktop_answers_status_rather_than_refusing_it() { // The point of binding the socket before the webview. `status` is the diff --git a/docs/local-capabilities-v1-quickstart.md b/docs/local-capabilities-v1-quickstart.md index a456a1b8..aea6fdda 100644 --- a/docs/local-capabilities-v1-quickstart.md +++ b/docs/local-capabilities-v1-quickstart.md @@ -108,6 +108,31 @@ Stop watching: gx-cli/target/gx unwatch /tmp/diagram.dot --json ``` +## A separate library: `GX_HOME` + +Graph Explorer keeps its state in `~/.graph-explorer` — `library/` and +`runtime/`. `GX_HOME` replaces that directory, and **both** `gx` and the desktop +read it, which is the point: they have to agree, or the library ends up in one +place and the runtime file naming its socket in another. + +```bash +GX_HOME=/tmp/scratch desktop/src-tauri/target/release/graph-explorer-desktop & +GX_HOME=/tmp/scratch gx-cli/target/gx status +``` + +Useful for a throwaway library, for keeping a second one, and for testing +against a running desktop without touching your real one. + +Redirecting `$HOME` does **not** work for this. The desktop follows it; `gx` is +a GraalVM native image and reads `user.home`, which on macOS comes from the +password database and ignores `$HOME` entirely — so `gx` would silently answer +about your real desktop. That is exactly what happened while verifying v0.9.4, +and the only tell was a fresh sandbox reporting six diagrams. + +`GX_HOME` moves gx's own data and nothing else. The access policy's denied roots +(`~/.ssh`, `~/.gnupg`, ...) keep reading your true home, so relocating a library +can never quietly un-deny a secret. A blank value reads as unset. + ## Optional policy controls Allowlist (only watch files under these roots): diff --git a/gx-cli/src/main/scala/org/jpablo/graphexplorer/gx/Main.scala b/gx-cli/src/main/scala/org/jpablo/graphexplorer/gx/Main.scala index ce22f6b8..9c48e2bc 100644 --- a/gx-cli/src/main/scala/org/jpablo/graphexplorer/gx/Main.scala +++ b/gx-cli/src/main/scala/org/jpablo/graphexplorer/gx/Main.scala @@ -1,6 +1,6 @@ package org.jpablo.graphexplorer.gx -import org.jpablo.graphexplorer.gxcore.fs.{AccessPolicy, Audit} +import org.jpablo.graphexplorer.gxcore.fs.{AccessPolicy, Audit, GxHome} import org.jpablo.graphexplorer.gxcore.rpc.{ChannelError, ControlChannel} import org.jpablo.graphexplorer.gxcore.store.LibraryStore @@ -22,8 +22,12 @@ object Main: private val DebugFlag = "--debug-protocol" def main(argv: Array[String]): Unit = - val home = Paths.get(sys.props.getOrElse("user.home", ".")) - val runtime = home.resolve(".graph-explorer").resolve("runtime") + // Resolved once, here, and threaded everywhere else. `$GX_HOME` moves the + // library, the control file and the audit log together — they only work as + // a set, since the runtime file is what names the socket for the library it + // belongs to. + val gxHome = GxHome.resolve() + val runtime = GxHome.runtimeDir(gxHome) val control = runtime.resolve("control.json") val args = argv.toVector @@ -35,7 +39,7 @@ object Main: else _ => () val env = CliEnv( - store = LibraryStore.default(home), + store = LibraryStore.default(gxHome), policy = AccessPolicy.fromEnv(), audit = Audit(runtime.resolve("audit.log.jsonl")), // The user's shell, not the process's idea of it. v1 learned this the hard diff --git a/gx-core/jvm/src/main/scala/org/jpablo/graphexplorer/gxcore/fs/GxHome.scala b/gx-core/jvm/src/main/scala/org/jpablo/graphexplorer/gxcore/fs/GxHome.scala new file mode 100644 index 00000000..c7d3a0ad --- /dev/null +++ b/gx-core/jvm/src/main/scala/org/jpablo/graphexplorer/gxcore/fs/GxHome.scala @@ -0,0 +1,54 @@ +package org.jpablo.graphexplorer.gxcore.fs + +import java.nio.file.{Path, Paths} + +/** Where Graph Explorer keeps its own state: `library/` and `runtime/`. + * + * `$GX_HOME` replaces that directory wholesale. Default: `~/.graph-explorer`. + * + * This exists because the two halves could not be pointed at the same place. + * The desktop resolves its home through `dirs::home_dir()`, which follows + * `$HOME`; `gx` is a GraalVM native image and reads `user.home`, which on macOS + * comes from the password database and ignores `$HOME` entirely. So a test that + * launched a desktop under a redirected `$HOME` and then asked `gx` about it + * was silently asking about the REAL desktop — which is exactly what happened + * while verifying v0.9.4, and the only tell was a sandbox library reporting six + * diagrams and a watched file it could not possibly have. + * + * One variable both sides read closes that. It is a testing affordance first, + * but it is equally the answer to "keep a second library" or "run against a + * throwaway one", neither of which had any answer before. + * + * NOT a general home override. The user's real home still decides what the + * access policy denies (`~/.ssh`, `~/.gnupg`, ...): relocating gx's data must + * never quietly un-deny a secret, so those keep reading the true home. + */ +object GxHome: + + val EnvVar = "GX_HOME" + + private val DefaultDirName = ".graph-explorer" + + /** Resolved once, at the edge of the process, and threaded from there. + * + * Both parameters are injected rather than read here so this is a pure + * function of its inputs — the env and the JVM's idea of home are exactly + * the two things a test cannot set without affecting the whole process. + */ + def resolve( + env: String => Option[String] = k => sys.env.get(k), + userHome: () => Path = () => Paths.get(sys.props.getOrElse("user.home", ".")) + ): Path = + env(EnvVar).map(_.trim).filter(_.nonEmpty) match + // Absolutised against the process's CWD, and normalised, so that a + // relative `GX_HOME=./scratch` means the same directory to every later + // path comparison — the library store compares paths to decide whether a + // name escapes its directory. + case Some(dir) => Paths.get(dir).toAbsolutePath.normalize() + case None => userHome().resolve(DefaultDirName) + + /** `$GX_HOME/library`, the directory `gx` and the desktop agree on. */ + def libraryDir(gxHome: Path): Path = gxHome.resolve("library") + + /** `$GX_HOME/runtime`: the control file, the socket, the audit log. */ + def runtimeDir(gxHome: Path): Path = gxHome.resolve("runtime") diff --git a/gx-core/jvm/src/main/scala/org/jpablo/graphexplorer/gxcore/store/LibraryStore.scala b/gx-core/jvm/src/main/scala/org/jpablo/graphexplorer/gxcore/store/LibraryStore.scala index efd272fe..c2861c99 100644 --- a/gx-core/jvm/src/main/scala/org/jpablo/graphexplorer/gxcore/store/LibraryStore.scala +++ b/gx-core/jvm/src/main/scala/org/jpablo/graphexplorer/gxcore/store/LibraryStore.scala @@ -1,10 +1,10 @@ package org.jpablo.graphexplorer.gxcore.store -import org.jpablo.graphexplorer.gxcore.fs.AtomicFiles +import org.jpablo.graphexplorer.gxcore.fs.{AtomicFiles, GxHome} import org.jpablo.graphexplorer.gxcore.model.* import java.nio.charset.StandardCharsets -import java.nio.file.{Files, Path, Paths} +import java.nio.file.{Files, Path} import scala.util.control.NonFatal enum StoreError derives CanEqual: @@ -138,9 +138,16 @@ final class LibraryStore(val root: Path) extends DiagramSink: catch case NonFatal(e) => Left(StoreError.Io(e.toString)) object LibraryStore: - /** `~/.graph-explorer/library`, the location `gx` and the desktop agree on. */ - def default(home: Path = Paths.get(sys.props.getOrElse("user.home", "."))): LibraryStore = - LibraryStore(home.resolve(".graph-explorer").resolve("library")) + /** `$GX_HOME/library`, or `~/.graph-explorer/library` — the location `gx` and + * the desktop agree on. + * + * The parameter is the GX HOME, not the user's: routed through [[GxHome]] so + * there is one definition of where the data lives. Two would be one too many + * — a half-applied override is worse than none, because it splits the library + * from the runtime file that names its socket. + */ + def default(gxHome: Path = GxHome.resolve()): LibraryStore = + LibraryStore(GxHome.libraryDir(gxHome)) /** An id becomes a filename, so it must not be able to escape the directory or * collide after normalisation. Anything outside a conservative set becomes diff --git a/gx-core/jvm/src/test/scala/org/jpablo/graphexplorer/gxcore/fs/GxHomeSpec.scala b/gx-core/jvm/src/test/scala/org/jpablo/graphexplorer/gxcore/fs/GxHomeSpec.scala new file mode 100644 index 00000000..e4bffe85 --- /dev/null +++ b/gx-core/jvm/src/test/scala/org/jpablo/graphexplorer/gxcore/fs/GxHomeSpec.scala @@ -0,0 +1,66 @@ +package org.jpablo.graphexplorer.gxcore.fs + +import munit.FunSuite + +import java.nio.file.{Path, Paths} + +/** `$GX_HOME` has to mean the same directory to `gx` and to the desktop, or it + * is worse than not existing: a library in one place and the runtime file that + * names its socket in another is a split nobody would think to look for. + * + * The desktop's half of this rule lives in main.rs + * (`graph_explorer_dir_from`), tested there. These are the same four cases. + */ +class GxHomeSpec extends FunSuite: + + private def home(path: String): () => Path = () => Paths.get(path) + private def env(pairs: (String, String)*): String => Option[String] = + val map = pairs.toMap + key => map.get(key) + + test("with no GX_HOME it is ~/.graph-explorer") { + assertEquals( + GxHome.resolve(env(), home("/Users/someone")), + Paths.get("/Users/someone/.graph-explorer") + ) + } + + test("GX_HOME replaces the directory, it does not nest inside it") { + // The variable names the data directory itself. Resolving it to + // `$GX_HOME/.graph-explorer` would put a hidden directory inside a path the + // caller chose explicitly, which is the opposite of what naming one is for. + assertEquals( + GxHome.resolve(env("GX_HOME" -> "/tmp/scratch"), home("/Users/someone")), + Paths.get("/tmp/scratch") + ) + } + + test("a relative GX_HOME is absolutised, so later path comparisons agree") { + // The library store decides whether a name escapes its directory by + // comparing paths. A relative root would make that comparison depend on the + // process's working directory, which `gx` deliberately reads from the + // user's shell rather than its own. + val resolved = GxHome.resolve(env("GX_HOME" -> "scratch"), home("/Users/someone")) + assert(resolved.isAbsolute, s"expected an absolute path, got $resolved") + assertEquals(resolved.getFileName.toString, "scratch") + } + + test("a blank GX_HOME means unset, not the filesystem root") { + // `export GX_HOME="$SOMETHING"` with SOMETHING unset is how a shell script + // passes through an absent value. Taking it literally would put the library + // at `/library`. + for blank <- List("", " ") do + assertEquals( + GxHome.resolve(env("GX_HOME" -> blank), home("/Users/someone")), + Paths.get("/Users/someone/.graph-explorer"), + s"blank value [$blank] should read as unset" + ) + } + + test("library and runtime hang off the same root") { + // They are only useful as a set: the runtime file names the socket for the + // library it belongs to, so a half-applied override splits them silently. + val root = GxHome.resolve(env("GX_HOME" -> "/tmp/scratch"), home("/Users/someone")) + assertEquals(GxHome.libraryDir(root), Paths.get("/tmp/scratch/library")) + assertEquals(GxHome.runtimeDir(root), Paths.get("/tmp/scratch/runtime")) + }