From d65f05ee0de4dc7242863f970c83dcdb322b5bb6 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Sun, 12 Jul 2026 19:23:28 -0700 Subject: [PATCH 1/6] feat: machine-wide control-token state dir + dig-node open handler (#501, #389) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deliverable A (#501): decouple the control token + paired-token store from the per-user config dir into a machine-wide, identity-INDEPENDENT state dir so the daemon (which may run as a service under a different OS account, e.g. Windows LocalSystem) and the operator CLI resolve the SAME token file. - Add `state::state_dir()`: Windows `%PROGRAMDATA%\DigNode`, Linux `/var/lib/dig-node` (fallback `/etc/dig-node`), macOS `/Library/Application Support/DigNode`; env override `DIG_NODE_STATE_DIR`; legacy per-user fallback for a non-service dev run (back-compat). - Move ONLY control-token + paired-tokens.json there; the bulk per-user `.dig` cache + config.json stay exactly as-is (#96). - Security: created dir/token get a restrictive ACL (Unix 0700 dir / 0600 file; Windows SYSTEM+Admins full + the installing user read, inheritance removed) — never world-readable. The ACL is applied only on FRESH create so a SYSTEM/root service re-run cannot clobber the installer's install-user grant. `dig-node install` pre-creates the dir as the interactive user; the service self-identifies via DIG_NODE_RUN_CONTEXT. - `dig-node pair` reads the token read-only (never mints a phantom the node won't trust) and surfaces a precise service-vs-user remedy; the control.* 401 names the exact path + remedy. Deliverable B (#389): implement `dig-node open ` (+ --json), the OS scheme-handler target. Strictly validates (only chia/urn-dig schemes; rejects file:/javascript:/data:, shell metacharacters, path traversal, non-64-hex store refs), then opens the default browser at the node's local serve URL via a no-shell launcher behind an injectable trait. Version: 0.26.0 -> 0.27.0 (minor; additive + back-compat). Refs #501 #389 Co-Authored-By: Claude --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/dig-node-service/src/cli.rs | 5 + crates/dig-node-service/src/control.rs | 182 ++++++-- crates/dig-node-service/src/lib.rs | 8 + crates/dig-node-service/src/main.rs | 10 + crates/dig-node-service/src/meta.rs | 7 +- crates/dig-node-service/src/open.rs | 384 ++++++++++++++++ crates/dig-node-service/src/pair.rs | 6 +- crates/dig-node-service/src/pairing.rs | 37 +- crates/dig-node-service/src/server.rs | 36 +- crates/dig-node-service/src/service.rs | 18 + crates/dig-node-service/src/state.rs | 422 ++++++++++++++++++ crates/dig-node-service/src/win_service.rs | 9 + .../dig-node-service/tests/content_serve.rs | 3 + crates/dig-node-service/tests/server.rs | 16 + 16 files changed, 1080 insertions(+), 67 deletions(-) create mode 100644 crates/dig-node-service/src/open.rs create mode 100644 crates/dig-node-service/src/state.rs diff --git a/Cargo.lock b/Cargo.lock index 0e340c1..16cf62b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1956,7 +1956,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.26.0" +version = "0.27.0" dependencies = [ "axum", "base64", diff --git a/Cargo.toml b/Cargo.toml index 2858d71..2b4bd0e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ edition = "2021" # (`[workspace.package].version`), so it MUST be set here for a release to fire # (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) keep their own # independent versions — only the released binary tracks the workspace version. -version = "0.26.0" +version = "0.27.0" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over diff --git a/crates/dig-node-service/src/cli.rs b/crates/dig-node-service/src/cli.rs index da2cb0e..03846f9 100644 --- a/crates/dig-node-service/src/cli.rs +++ b/crates/dig-node-service/src/cli.rs @@ -96,6 +96,9 @@ impl ExitCode { match e.kind() { PermissionDenied => ExitCode::PermissionDenied, AddrInUse | AddrNotAvailable => ExitCode::BindFailed, + // A bad argument surfaced as `InvalidInput` (e.g. `dig-node open` rejecting a + // non-DIG/malformed link) is a USAGE error, not a generic I/O failure. + InvalidInput => ExitCode::Usage, _ => ExitCode::IoError, } } @@ -204,6 +207,8 @@ mod tests { assert_eq!(ExitCode::from_io_error(&bind), ExitCode::BindFailed); let other = std::io::Error::other("x"); assert_eq!(ExitCode::from_io_error(&other), ExitCode::IoError); + let usage = std::io::Error::new(std::io::ErrorKind::InvalidInput, "bad link"); + assert_eq!(ExitCode::from_io_error(&usage), ExitCode::Usage); } #[test] diff --git a/crates/dig-node-service/src/control.rs b/crates/dig-node-service/src/control.rs index e011c51..b2ad109 100644 --- a/crates/dig-node-service/src/control.rs +++ b/crates/dig-node-service/src/control.rs @@ -17,13 +17,14 @@ //! 1. **Loopback-only.** The whole server binds `127.0.0.1` (see [`crate::config`]), //! so nothing off-machine can reach any method. //! 2. **Local authorization** for the mutating control namespace. A random -//! **control token** is generated at first run into the node's config dir -//! (`/control-token`, next to dig-node's `config.json`) with -//! owner-only permissions where the OS supports it. A same-host controller reads -//! that file (it can, because it runs as the same user on the same machine) and -//! presents the token on every `control.*` call — as the `X-Dig-Control-Token` -//! request header or a `params._control_token` field. The READ methods are NOT -//! gated; only `control.*` requires the token. +//! **control token** is generated at first run into the machine-wide, identity- +//! INDEPENDENT state dir (`/control-token` — [`crate::state`], #501) with +//! a restrictive ACL. A same-host controller reads that file and presents the token +//! on every `control.*` call — as the `X-Dig-Control-Token` request header or a +//! `params._control_token` field. The READ methods are NOT gated; only `control.*` +//! requires the token. The token lives in [`crate::state::state_dir`] (NOT the +//! per-user config dir) so the daemon (which may run as a service under a different +//! OS account) and the operator CLI resolve the SAME file. //! //! This is the standard "local capability file" pattern (cf. Chia's `daemon` / //! Bitcoin's cookie auth): possession of the on-disk token = authorization, so a @@ -86,25 +87,94 @@ pub fn is_pairing_admin_method(method: &str) -> bool { ) } -/// The path to the control-token file, given the node's config dir. The config dir -/// is `config.json`'s parent (dig-node's `config_path()`), so the token lives beside -/// the shared config. PURE (path math only). -pub fn control_token_path(config_path: &Path) -> PathBuf { - config_path - .parent() - .map(|p| p.join(CONTROL_TOKEN_FILE)) - .unwrap_or_else(|| PathBuf::from(CONTROL_TOKEN_FILE)) +/// The path to the control-token file: `/control-token`, where the state +/// dir is the machine-wide, identity-INDEPENDENT daemon state dir (#501, +/// [`crate::state::state_dir`]) — NOT the per-user config dir. Decoupling the token +/// from `config_path()` is the fix for the service-vs-user path split: the running +/// daemon and the operator CLI resolve this ONE path regardless of which OS user each +/// runs as, so the CLI reads the SAME token the service wrote. +pub fn control_token_path() -> PathBuf { + crate::state::state_dir().join(CONTROL_TOKEN_FILE) } /// Load the control token, generating + persisting a fresh one if absent. /// /// The token is 32 random bytes rendered as 64-hex. Generated at RUNTIME into the -/// node's config dir on first call; subsequent calls (and other processes sharing -/// the dir) read the same value. Written with owner-only permissions on Unix -/// (`0600`) so another user on the box cannot read it. Never committed. +/// machine-wide state dir ([`control_token_path`]) on first call; subsequent calls (and +/// other processes / users on the box) read the same value. The dir + file are created +/// with a RESTRICTIVE ACL (owner/SYSTEM + Administrators, the creating user; never +/// world/all-users-readable — see [`crate::state`]). Never committed. pub fn load_or_create_token() -> std::io::Result { - let path = control_token_path(&dig_node_core::config_path()); - load_or_create_token_at(&path) + load_or_create_token_at(&control_token_path()) +} + +/// A precise, service-aware remedy for a control-token authorization failure (#501). +/// +/// The classic failure is the service-vs-user PATH/PERMISSION split: the node runs as a +/// service (Windows LocalSystem / a root daemon) and minted `control-token` in the +/// machine-wide state dir with restrictive perms, but the interactive user running +/// `dig-node pair` / a `control.*` call cannot READ it. This inspects the resolved token +/// path from the CALLER's perspective and returns the exact fix (which dir + that it +/// needs elevation or the install-user's read ACL), instead of the generic hint. +pub fn control_token_remedy() -> String { + let path = control_token_path(); + let dir = path + .parent() + .map(|p| p.display().to_string()) + .unwrap_or_default(); + // Read once: an Ok read = readable (whatever the contents); an Err with the file present + // = the service-vs-user ACL split; absent = the node has not minted one yet. + let read = std::fs::read_to_string(&path); + let readable = read.is_ok(); + let present = readable || path.exists(); + if present && !readable { + format!( + "the node's control token at {} exists but is NOT readable by your account — \ + the node runs as a service under a different account (Windows LocalSystem / a \ + root daemon). Re-run this command elevated (Administrator on Windows, sudo on \ + Unix), or have the installer grant your account read access to {}.", + path.display(), + dir + ) + } else if !present { + format!( + "no control token found at {}. Start the node so it mints one \ + (`dig-node run`, or `dig-node start` for the installed service), then retry.", + path.display() + ) + } else { + format!( + "the presented control token was not accepted. Ensure the node and this command \ + resolve the SAME state dir ({dir}) — if you set DIG_NODE_STATE_DIR it must match \ + on both the node and this command." + ) + } +} + +/// Read the master control token WITHOUT creating one — the OPERATOR-side load (`dig-node +/// pair` / any local control CLI, #501). It must NEVER mint a token: minting a fresh token +/// the running node does not trust is the exact original bug (the CLI wrote its own token to +/// a per-user path the service never read). On a missing/unreadable/blank token it returns a +/// rich [`std::io::Error`] carrying [`control_token_remedy`] — the precise service-vs-user +/// remedy — with the error KIND chosen so the CLI maps it to the right exit code +/// ([`crate::cli::ExitCode::from_io_error`]): `PermissionDenied` (the ACL split → "elevate") +/// when the file is present but unreadable, else `NotFound` ("start the node"). +pub fn load_token_readonly() -> std::io::Result { + let path = control_token_path(); + match std::fs::read_to_string(&path) { + Ok(s) if !s.trim().is_empty() => Ok(s.trim().to_string()), + // Present but blank/unreadable, or absent → never mint; surface the exact remedy. + other => { + let kind = if path.exists() { + std::io::ErrorKind::PermissionDenied + } else { + std::io::ErrorKind::NotFound + }; + // Drop the raw read value; the remedy is the actionable message. + drop(other); + Err(std::io::Error::new(kind, control_token_remedy())) + } + } } /// [`load_or_create_token`] for an explicit path (so tests use a temp dir and never @@ -119,24 +189,24 @@ pub fn load_or_create_token_at(path: &Path) -> std::io::Result { } let token = generate_token(); if let Some(dir) = path.parent() { - std::fs::create_dir_all(dir)?; + // Create the state dir with a RESTRICTIVE ACL (not the world-readable default of + // a machine-wide `%PROGRAMDATA%`) — see [`crate::state::ensure_dir_restricted`]. + crate::state::ensure_dir_restricted(dir)?; } std::fs::write(path, &token)?; restrict_permissions(path); Ok(token) } -/// Restrict the token file to owner read/write where the OS supports it (Unix -/// `0600`). On Windows the default ACL on a per-user profile dir already scopes it -/// to the user, and loopback-only binding is the primary control; this is -/// best-effort defense-in-depth, so a failure is ignored. -#[cfg(unix)] +/// Restrict a control/auth file so it is not readable by every local user. Delegates to +/// [`crate::state::restrict_file`]: Unix `0600`; on Windows the file inherits the tight, +/// inheritable ACL of the machine-wide state dir ([`crate::state::ensure_dir_restricted`]) — +/// critical now that the file lives under `%PROGRAMDATA%`, whose default would otherwise let +/// every local user read it (a local privilege-escalation vector, #501). Best-effort (a +/// failure is ignored — loopback bind + token possession are the primary gate). pub(crate) fn restrict_permissions(path: &Path) { - use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); + crate::state::restrict_file(path); } -#[cfg(not(unix))] -pub(crate) fn restrict_permissions(_path: &Path) {} /// Generate a fresh 64-hex control token from 32 bytes of OS randomness. fn generate_token() -> String { @@ -453,6 +523,10 @@ pub struct ControlCtx { pub node: Arc, /// The node's config.json path (pins + upstream override live here). pub config_path: PathBuf, + /// The machine-wide daemon STATE dir (#501) — where the control token + + /// `paired-tokens.json` live (NOT the per-user config dir). The pairing-admin + /// methods read/write the paired-token store from here. + pub state_dir: PathBuf, /// The loopback `host:port` the node is bound to (status/config). pub addr: String, /// The upstream DIG RPC the node proxies/syncs to. @@ -490,11 +564,11 @@ pub async fn dispatch_control(ctx: &ControlCtx, id: Value, method: &str, params: "control.sync.trigger" => sync_trigger(ctx, id, params).await, // Pairing administration (#280) — reached only with the MASTER token (the // gate blocks a paired token from these, see `is_pairing_admin_method`). - "control.pairing.list" => crate::pairing::list(&ctx.pairings, &ctx.config_path, id), + "control.pairing.list" => crate::pairing::list(&ctx.pairings, &ctx.state_dir, id), "control.pairing.approve" => { - crate::pairing::approve(&ctx.pairings, &ctx.config_path, id, params) + crate::pairing::approve(&ctx.pairings, &ctx.state_dir, id, params) } - "control.pairing.revoke" => crate::pairing::revoke(&ctx.config_path, id, params), + "control.pairing.revoke" => crate::pairing::revoke(&ctx.state_dir, id, params), // Control methods the shell does not own are delegated to the NODE's own // control surface (`control.peerStatus` / `control.subscribe` / // `control.unsubscribe` / `control.listSubscriptions` — the node's persisted @@ -984,6 +1058,48 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// SECURITY (#501): the control token grants full local control, so the created + /// file MUST NOT be readable by other local users. On Unix that is a hard `0600` + /// assertion (no group/other bits) — the CI-gated path (CI runs on Linux). On + /// Windows the restriction is applied via `icacls` (asserted separately in a + /// Windows-gated test / by the orchestrator's adversarial ACL check). + #[cfg(unix)] + #[test] + fn created_token_file_is_not_world_or_group_readable() { + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir().join(format!( + "dig-node-token-perms-{}-{}", + std::process::id(), + line!() + )); + let path = dir.join(CONTROL_TOKEN_FILE); + let _ = std::fs::remove_dir_all(&dir); + load_or_create_token_at(&path).unwrap(); + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!( + mode & 0o077, + 0, + "token must have NO group/other permission bits (got {mode:o})" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + /// The remedy hint names the concrete token path and, when the token is absent from + /// the caller's perspective, tells them to start the node — never the old generic + /// "" wording. + #[test] + fn control_token_remedy_names_a_concrete_path() { + let remedy = control_token_remedy(); + assert!( + remedy.contains("control token") || remedy.contains("control-token"), + "remedy should mention the control token: {remedy}" + ); + assert!( + remedy.contains("dig-node") || remedy.contains("state dir") || remedy.contains('/') || remedy.contains('\\'), + "remedy should name a path or command: {remedy}" + ); + } + #[test] fn parse_store_ref_validates_hex_and_splits_capsule() { let store = "a".repeat(64); diff --git a/crates/dig-node-service/src/lib.rs b/crates/dig-node-service/src/lib.rs index 35c1448..c678f4c 100644 --- a/crates/dig-node-service/src/lib.rs +++ b/crates/dig-node-service/src/lib.rs @@ -38,11 +38,19 @@ pub mod config; pub mod content; pub mod control; pub mod meta; +/// `dig-node open ` (#389): the OS scheme-handler target the +/// installer registers for `chia://` + `urn:dig:chia:`. Strictly validates the untrusted +/// handler argument, then opens the user's default browser at the resolving URL. See [`open`]. +pub mod open; pub mod pair; pub mod pairing; pub mod rpc; pub mod server; pub mod service; +/// The machine-wide, identity-independent daemon STATE dir (#501): where the control token + +/// paired-token store live so the daemon (which may run as a service under a different OS +/// account) and the operator CLI resolve the SAME files. See [`state`]. +pub mod state; pub mod wallet_authz; /// Windows Service Control Protocol entrypoint — only meaningful on Windows, where diff --git a/crates/dig-node-service/src/main.rs b/crates/dig-node-service/src/main.rs index c979312..997277f 100644 --- a/crates/dig-node-service/src/main.rs +++ b/crates/dig-node-service/src/main.rs @@ -25,6 +25,7 @@ use clap::{Parser, Subcommand}; use dig_node_service::cli::{error_envelope, success_envelope, ExitCode, Outcome}; use dig_node_service::config::Config; +use dig_node_service::open; use dig_node_service::pair::{self, PairAction}; use dig_node_service::{serve, service, VERSION}; @@ -70,6 +71,13 @@ enum Command { #[command(subcommand)] action: Option, }, + /// Open a DIG link in the default browser (#389). The OS scheme-handler target the + /// installer registers for `chia://` + `urn:dig:chia:`. Accepts ONLY those two schemes, + /// resolves via the local node's serve URL, and never invokes a shell. + Open { + /// The DIG link (`chia://[:]/` or `urn:dig:chia:<…>`). + link: String, + }, } /// `dig-node pair` sub-actions. With none, lists pending requests + issued tokens. @@ -100,6 +108,7 @@ impl Command { Command::Stop => "stop", Command::Status => "status", Command::Pair { .. } => "pair", + Command::Open { .. } => "open", } } } @@ -129,6 +138,7 @@ fn main() -> std::process::ExitCode { }; render(pair::run(&config, pair_action), action, json) } + Command::Open { link } => render(open::run(&config, &link), action, json), }; std::process::ExitCode::from(exit.code()) } diff --git a/crates/dig-node-service/src/meta.rs b/crates/dig-node-service/src/meta.rs index 9f8175e..eb0fffd 100644 --- a/crates/dig-node-service/src/meta.rs +++ b/crates/dig-node-service/src/meta.rs @@ -452,8 +452,8 @@ pub enum ErrorCode { /// `-32030` — a `control.*` (CONTROL/admin) method was called without a valid /// local control token. The control surface is loopback-only AND locally /// authorized: a same-host controller (the DIG Browser "My Node" UI) must read - /// the node's control token from its config dir and present it. Read methods - /// are NOT gated; only the mutating/management control namespace is. Shell error. + /// the node's control token from the machine-wide state dir and present it. Read + /// methods are NOT gated; only the mutating/management control namespace is. Shell error. /// (Canonical dig-rpc-types §10 code; `-32020` is RESERVED for onion routing.) Unauthorized, /// `-32031` — a control operation the embedded dig-node cannot perform on this @@ -710,7 +710,8 @@ pub fn openrpc_document() -> Value { let auth_note = if m.requires_auth { " CONTROL method: requires the local control token (loopback-only + \ locally authorized — present it as the X-Dig-Control-Token header or \ - params._control_token; read it from /control-token)." + params._control_token; read it from the machine-wide state dir's \ + control-token file, e.g. via `dig-node pair`)." } else { "" }; diff --git a/crates/dig-node-service/src/open.rs b/crates/dig-node-service/src/open.rs new file mode 100644 index 0000000..043bac6 --- /dev/null +++ b/crates/dig-node-service/src/open.rs @@ -0,0 +1,384 @@ +//! `dig-node open ` (#389) — the OS scheme-handler target. +//! +//! The dig-installer registers the OS handlers for `chia://` and `urn:dig:chia:` to invoke +//! `dig-node open "%1"`. This subcommand is therefore the OS-level fallback resolver for a DIG +//! link that no in-browser DIG extension intercepted (a link clicked outside a browser, or in a +//! browser without the extension): it opens the user's DEFAULT browser at the node's LOCAL serve +//! URL (`/s/[:]/`, the #289 plaintext content surface), so the node — the +//! resolver of record (#365 thin-client) — serves the decrypted content and any installed DIG +//! extension can still verify it. It NEVER opens dig-node's own GUI (it has none). +//! +//! # Security — the argument is UNTRUSTED +//! +//! `%1` arrives from an OS protocol handler, so a hostile web page can invoke the registered +//! scheme with an attacker-chosen argument. This module therefore: +//! +//! 1. **Validates STRICTLY.** Only `chia://` and `urn:dig:chia:` are accepted (case-insensitive +//! scheme); every other scheme (`file:`, `javascript:`, `data:`, `http:`, …) is rejected. The +//! store reference must be canonical 64-hex (`storeId` or `storeId:root`). Shell +//! metacharacters, control characters, whitespace, and `..` path traversal are rejected +//! outright — even though the launch path never uses a shell (defense in depth). +//! 2. **Never touches a shell.** The resolved URL is handed to the OS "open a URL" facility as a +//! SINGLE, non-shell argv entry (`rundll32 url.dll,FileProtocolHandler` on Windows, +//! `xdg-open` on Linux, `/usr/bin/open` on macOS) — never `cmd /c start` / `sh -c`, so even a +//! crafted argument cannot inject a command. The launcher is behind [`UrlLauncher`] so tests +//! assert the exact URL without spawning a real browser. + +use serde_json::json; + +use crate::cli::Outcome; +use crate::config::Config; + +/// Shell metacharacters (and quoting/grouping characters) rejected anywhere in the link. The +/// launch path never uses a shell, so this is defense-in-depth against the untrusted OS argument +/// — it also keeps a well-formed DIG link from ever carrying surprising bytes into the serve URL. +const DISALLOWED: &[char] = &[ + '&', '|', ';', '`', '$', '<', '>', '(', ')', '{', '}', '[', ']', '!', '*', '"', '\'', '\\', + '^', '\n', '\r', ' ', '\t', +]; + +/// A validated, normalized DIG link: a 64-hex store id, an optional 64-hex root (a capsule +/// pin), and the (traversal-free) resource path within the store. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DigLink { + /// The 64-hex store id. + pub store_id: String, + /// The optional 64-hex root hash (present for a `storeId:root` capsule reference). + pub root: Option, + /// The resource path within the store (may be empty for the store root). + pub path: String, +} + +impl DigLink { + /// The `storeId` or `storeId:root` reference the `/s//…` serve route expects. + fn store_ref(&self) -> String { + match &self.root { + Some(r) => format!("{}:{}", self.store_id, r), + None => self.store_id.clone(), + } + } +} + +/// Abstracts the OS "open this URL in the default browser / protocol handler" action so the +/// launch path is unit-testable (a fake asserts the exact URL) and the real implementation never +/// goes through a shell. `url` is ALREADY strictly validated + built by this module. +pub trait UrlLauncher { + /// Open `url` in the user's default browser. Implementations MUST pass `url` as a single, + /// non-shell argument. + fn open_url(&self, url: &str) -> std::io::Result<()>; +} + +/// The real OS launcher. Opens `url` via a per-OS facility with the URL as a SINGLE argv entry — +/// never a shell — so a crafted URL cannot inject a command. Fire-and-forget: a failure to SPAWN +/// the launcher (e.g. `xdg-open` not installed) is surfaced; the launcher's own exit code is not +/// awaited (it hands off to the browser and exits). +pub struct OsLauncher; + +impl UrlLauncher for OsLauncher { + fn open_url(&self, url: &str) -> std::io::Result<()> { + use std::process::{Command, Stdio}; + let mut cmd; + #[cfg(target_os = "windows")] + { + // `rundll32 url.dll,FileProtocolHandler ` invokes the registered URL/protocol + // handler with the URL as one argv entry and NO shell — unlike `cmd /c start`, which + // would parse `&`/`^`/`|` in the argument. + cmd = Command::new("rundll32.exe"); + cmd.arg("url.dll,FileProtocolHandler").arg(url); + } + #[cfg(target_os = "macos")] + { + cmd = Command::new("/usr/bin/open"); + cmd.arg(url); + } + #[cfg(all(unix, not(target_os = "macos")))] + { + cmd = Command::new("xdg-open"); + cmd.arg(url); + } + #[cfg(not(any(windows, unix)))] + { + return Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "dig-node open: no URL launcher for this platform", + )); + } + #[cfg(any(windows, unix))] + { + cmd.stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + Ok(()) + } + } +} + +/// Run `dig-node open `: validate + normalize the link, build the local serve URL, and open +/// it in the default browser via the real [`OsLauncher`]. See [`run_with`] for the injectable form. +pub fn run(config: &Config, link: &str) -> std::io::Result { + run_with(config, link, &OsLauncher) +} + +/// [`run`] with an injected [`UrlLauncher`] (so tests assert the exact URL without spawning a +/// browser). A rejected link returns an `InvalidInput` error (→ `USAGE` exit) and the launcher is +/// NEVER invoked. +pub fn run_with( + config: &Config, + link: &str, + launcher: &dyn UrlLauncher, +) -> std::io::Result { + let parsed = normalize(link).map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("dig-node open: {e}"), + ) + })?; + let url = serve_url(config, &parsed); + launcher.open_url(&url)?; + Ok(Outcome::new( + format!("dig-node: opening {url} in your default browser"), + json!({ + "opened": true, + "url": url, + "store_id": parsed.store_id, + "root": parsed.root, + "path": parsed.path, + }), + )) +} + +/// Strictly validate + normalize a DIG link into a [`DigLink`]. PURE. Accepts ONLY `chia://` and +/// `urn:dig:chia:` (case-insensitive scheme); rejects any other scheme, shell metacharacters, +/// control characters, whitespace, `..` path traversal, and a non-64-hex store reference. +pub fn normalize(input: &str) -> Result { + let s = input.trim(); + if s.is_empty() { + return Err("empty link".to_string()); + } + if let Some(bad) = s.chars().find(|c| c.is_control()) { + return Err(format!("control character not allowed (U+{:04X})", bad as u32)); + } + if let Some(bad) = s.chars().find(|c| DISALLOWED.contains(c)) { + return Err(format!("disallowed character in link: {bad:?}")); + } + // Scheme gate — ONLY chia:// or urn:dig:chia: (case-insensitive), nothing else. + let rest = strip_prefix_ci(s, "chia://") + .or_else(|| strip_prefix_ci(s, "urn:dig:chia:")) + .ok_or_else(|| { + "only chia:// and urn:dig:chia: links are allowed (this link's scheme is rejected)" + .to_string() + })?; + // Drop any query/fragment — the serve path is store-ref + resource path only. + let rest = rest + .split(['?', '#']) + .next() + .unwrap_or("") + .trim_start_matches('/'); + // Split the store reference from the resource path. + let (store_ref, path) = match rest.split_once('/') { + Some((r, p)) => (r, p), + None => (rest, ""), + }; + if path.split('/').any(|seg| seg == "..") { + return Err("path traversal (`..`) not allowed".to_string()); + } + // The store reference MUST be canonical 64-hex (storeId or storeId:root) — this rejects any + // non-hex authority (a hostname, an IP, an attacker string) outright. + let (store_id, root) = crate::control::parse_store_ref(store_ref)?; + Ok(DigLink { + store_id, + root, + path: path.to_string(), + }) +} + +/// Build the node's LOCAL serve URL for a validated link: `http://:/s//` +/// (the #289 plaintext content route). The host/port come from the node's own config (default +/// `localhost:9778`), so the URL always reaches the running node on this machine. +fn serve_url(config: &Config, link: &DigLink) -> String { + let host = browser_host(config); + let port = config.port; + let store_ref = link.store_ref(); + if link.path.is_empty() { + format!("http://{host}:{port}/s/{store_ref}/") + } else { + format!("http://{host}:{port}/s/{store_ref}/{}", link.path) + } +} + +/// The host to put in the browser URL: the operator's explicit `DIG_NODE_HOST` when set (bracketed +/// for IPv6), else `localhost` — friendlier than `127.0.0.1` and equally reachable (the node binds +/// both loopback families on the port). +fn browser_host(config: &Config) -> String { + match config.host { + Some(ip) if ip.is_ipv6() => format!("[{ip}]"), + Some(ip) => ip.to_string(), + None => "localhost".to_string(), + } +} + +/// Case-insensitive prefix strip that is safe on non-ASCII input (never panics on a char +/// boundary): returns the remainder after `prefix` when `s` starts with it ignoring ASCII case. +fn strip_prefix_ci<'a>(s: &'a str, prefix: &str) -> Option<&'a str> { + let head = s.get(..prefix.len())?; + if head.eq_ignore_ascii_case(prefix) { + Some(&s[prefix.len()..]) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + fn store() -> String { + "a".repeat(64) + } + fn root() -> String { + "b".repeat(64) + } + + /// A test launcher that records the URL it was asked to open (and proves the real launcher was + /// never reached, so no browser/shell is spawned in CI). + #[derive(Default)] + struct FakeLauncher { + opened: Mutex>, + } + impl UrlLauncher for FakeLauncher { + fn open_url(&self, url: &str) -> std::io::Result<()> { + self.opened.lock().unwrap().push(url.to_string()); + Ok(()) + } + } + + #[test] + fn accepts_chia_scheme_with_store_only() { + let n = normalize(&format!("chia://{}", store())).unwrap(); + assert_eq!(n.store_id, store()); + assert_eq!(n.root, None); + assert_eq!(n.path, ""); + } + + #[test] + fn accepts_chia_scheme_with_capsule_and_path() { + let n = normalize(&format!("chia://{}:{}/index.html", store(), root())).unwrap(); + assert_eq!(n.store_id, store()); + assert_eq!(n.root.as_deref(), Some(root().as_str())); + assert_eq!(n.path, "index.html"); + } + + #[test] + fn accepts_urn_dig_chia_scheme() { + let n = normalize(&format!("urn:dig:chia:{}/a/b.css", store())).unwrap(); + assert_eq!(n.store_id, store()); + assert_eq!(n.path, "a/b.css"); + } + + #[test] + fn scheme_match_is_case_insensitive() { + assert!(normalize(&format!("CHIA://{}", store())).is_ok()); + assert!(normalize(&format!("Urn:Dig:Chia:{}", store())).is_ok()); + } + + #[test] + fn strips_query_and_fragment() { + let n = normalize(&format!("chia://{}/p?x=1#frag", store())).unwrap(); + assert_eq!(n.path, "p"); + } + + #[test] + fn rejects_dangerous_schemes() { + for bad in [ + "file:///etc/passwd", + "javascript:alert(1)", + "data:text/html,