Skip to content
Merged
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
99 changes: 92 additions & 7 deletions desktop/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,8 @@ fn default_denied_roots() -> Vec<PathBuf> {
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"));
Expand Down Expand Up @@ -610,9 +612,49 @@ fn runtime_file_path() -> Result<PathBuf> {
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<PathBuf> {
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<std::ffi::OsString>,
home_dir: Option<PathBuf>,
) -> Result<PathBuf> {
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<PathBuf> {
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<PathBuf> {
Expand Down Expand Up @@ -2023,11 +2065,7 @@ fn set_owner_only_permissions(_path: &Path) -> Result<()> {
// how V-13's content hash diverged.

fn library_dir_path() -> Result<PathBuf> {
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
Expand Down Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions docs/local-capabilities-v1-quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
12 changes: 8 additions & 4 deletions gx-cli/src/main/scala/org/jpablo/graphexplorer/gx/Main.scala
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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"))
}