From dd4563eb3bbf93e82798219e6cf2072dc8df54c1 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 31 Jul 2026 07:12:02 -0700 Subject: [PATCH 1/5] chore: open #526 dig-node lane Scope-explicit service verbs so an elevated installer can register a system-level, reboot-surviving dig-node service. Co-Authored-By: Claude From 2f6de256b018ae875f453204af8b652f834e0821 Mon Sep 17 00:00:00 2001 From: Michael Taylor Date: Fri, 31 Jul 2026 07:31:55 -0700 Subject: [PATCH 2/5] feat(service): scope-explicit install/uninstall/start/stop via --scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retire the unconditional `PREFERS_USER_LEVEL` decision const and thread a runtime ServiceScope through the service verbs, so an elevated installer can register a system-level (boot-started) dig-node service on a headless host while an unelevated desktop install keeps today's user-level behaviour. * resolve_scope(choice, os_supports_user, is_root) is the ONE scope decision and is PURE: every input is a parameter, so the complete 12-row decision table is asserted on any host at any privilege level. * install clears any registration at the OTHER scope BEFORE creating at the requested one, so a host upgrading from a user-level install never runs two units racing for the node's port. * uninstall --scope auto sweeps both scopes and reports per scope; anything short of a complete removal is an error, never a silent success. * The §565 privileged-target gate now genuinely fires on unix system scope, so its refusal names the offending path LEVEL instead of just the program path. Refs #526. Co-Authored-By: Claude --- crates/dig-node-service/src/entrypoint.rs | 54 +- crates/dig-node-service/src/security.rs | 11 +- crates/dig-node-service/src/service.rs | 946 +++++++++++++++++++--- 3 files changed, 890 insertions(+), 121 deletions(-) diff --git a/crates/dig-node-service/src/entrypoint.rs b/crates/dig-node-service/src/entrypoint.rs index 1629ef1..41ea5d0 100644 --- a/crates/dig-node-service/src/entrypoint.rs +++ b/crates/dig-node-service/src/entrypoint.rs @@ -36,8 +36,24 @@ use crate::control_cli::{self, ControlAction}; use crate::open; use crate::pair::{self, PairAction}; use crate::peers::{self, BanState, PeersAction}; +use crate::service::ScopeChoice; use crate::{serve, service, VERSION}; +/// The shared `--scope ` flag carried by every service verb (#526). +/// +/// Flattened into each verb rather than declared four times, so the flag's spelling, default and +/// help text can never drift between `install`, `uninstall`, `start` and `stop`. The default is +/// `auto`, so a caller that passes no flag at all — including a dig-installer release predating +/// this flag — behaves exactly as it did before. +#[derive(clap::Args)] +struct ScopeArg { + /// Which OS scope to act on: `system` (machine-wide, starts at boot, needs root/Administrator), + /// `user` (per-user, no elevation, starts with your login session), or `auto` — system when + /// running elevated, user otherwise. Windows has only system scope. + #[arg(long = "scope", value_enum, default_value_t = ScopeChoice::Auto)] + choice: ScopeChoice, +} + #[derive(Parser)] #[command( // A default only: [`run`] overrides both the displayed name and the usage `bin_name` @@ -68,13 +84,25 @@ enum Command { #[command(hide = true)] RunService, /// Register the node as an auto-starting OS service. - Install, + Install { + #[command(flatten)] + scope: ScopeArg, + }, /// Remove the OS service. - Uninstall, + Uninstall { + #[command(flatten)] + scope: ScopeArg, + }, /// Start the installed service. - Start, + Start { + #[command(flatten)] + scope: ScopeArg, + }, /// Stop the running service. - Stop, + Stop { + #[command(flatten)] + scope: ScopeArg, + }, /// Report whether the node is serving (probes /health). Status, /// Pair a browser controller (the DIG Chrome extension) with this node (#280): @@ -291,10 +319,10 @@ impl Command { fn action(&self) -> &'static str { match self { Command::Run | Command::RunService => "run", - Command::Install => "install", - Command::Uninstall => "uninstall", - Command::Start => "start", - Command::Stop => "stop", + Command::Install { .. } => "install", + Command::Uninstall { .. } => "uninstall", + Command::Start { .. } => "start", + Command::Stop { .. } => "stop", Command::Status => "status", Command::Pair { .. } => "pair", Command::Open { .. } => "open", @@ -377,10 +405,12 @@ pub fn run() -> std::process::ExitCode { let exit = match command { Command::Run => render_serve(block_on_serve(config), action, json), Command::RunService => render_serve(run_service(config), action, json), - Command::Install => render(service::install(&config), action, json), - Command::Uninstall => render(service::uninstall(), action, json), - Command::Start => render(service::start(), action, json), - Command::Stop => render(service::stop(), action, json), + Command::Install { scope } => { + render(service::install(&config, scope.choice), action, json) + } + Command::Uninstall { scope } => render(service::uninstall(scope.choice), action, json), + Command::Start { scope } => render(service::start(scope.choice), action, json), + Command::Stop { scope } => render(service::stop(scope.choice), action, json), Command::Status => render_status(service::status(&config), action, json), Command::Pair { action: pair_cmd } => { let pair_action = match pair_cmd { diff --git a/crates/dig-node-service/src/security.rs b/crates/dig-node-service/src/security.rs index 5a2b4a1..75f8cc1 100644 --- a/crates/dig-node-service/src/security.rs +++ b/crates/dig-node-service/src/security.rs @@ -40,7 +40,16 @@ use std::path::Path; /// component is untrusted. `dir.ancestors()` always yields at least `dir`, so an empty chain can /// never vacuously pass. pub fn dir_is_privileged(dir: &Path) -> bool { - dir.ancestors().all(component_is_privileged) + first_unprivileged_ancestor(dir).is_none() +} + +/// The FIRST ancestor component of `dir` (walking leaf → filesystem root) that fails the trust bar, +/// or `None` when the whole chain is privileged. The diagnostic twin of [`dir_is_privileged`]: the +/// boolean answers *whether* a path is trustworthy, this answers *which level* is not — so a refusal +/// can name the offending directory instead of leaving an operator to bisect the chain by hand +/// (#526: the system-service install gate must be actionable, never a silent downgrade). +pub fn first_unprivileged_ancestor(dir: &Path) -> Option<&Path> { + dir.ancestors().find(|c| !component_is_privileged(c)) } /// Whether a SINGLE existing path `component` clears the trust bar: privileged-owned, not diff --git a/crates/dig-node-service/src/service.rs b/crates/dig-node-service/src/service.rs index 2ecfc30..b654977 100644 --- a/crates/dig-node-service/src/service.rs +++ b/crates/dig-node-service/src/service.rs @@ -40,14 +40,27 @@ //! backend) and could flip the installer's reported `installed` status to `false` even though //! the service is up. So `reinstall` here stops at **create** — a caller starts it explicitly. //! -//! Install level by platform: -//! * Linux (systemd) / macOS (launchd) — **user-level** by default (`--user` / -//! `gui` domain), so no root/sudo is needed and the service runs as the -//! installing user. -//! * Windows (SCM) — **system-level only**: the Service Control Manager has no -//! per-user services, so `install`/`uninstall` require an **elevated -//! (Administrator)** console. This is detected up front and reported with a -//! clear message rather than failing deep inside `sc.exe`. +//! ## Install SCOPE (#526) +//! +//! Every service verb is **scope-explicit**: `--scope ` (default `auto`) selects +//! which OS scope ([`ServiceScope`]) the registration lives in, resolved by the one pure decision +//! function [`resolve_scope`]. +//! +//! * Linux (systemd) / macOS (launchd) — BOTH scopes exist. `auto` picks **system** when running +//! as root (what an elevated `dig-installer` needs: a `multi-user.target` unit / a +//! `/Library/LaunchDaemons` daemon that starts at BOOT with no login session — matching what +//! dig-node's own native packages already register) and **user** otherwise (the historical +//! no-elevation desktop install: a `systemd --user` unit / a `gui/` agent). +//! Root has NO systemd `--user` D-Bus session, which is precisely why the previous +//! unconditional user-level preference made an elevated headless install impossible. +//! * Windows (SCM) — **system scope only**: the Service Control Manager has no per-user services, +//! so every choice resolves to system and `install`/`uninstall` require an **elevated +//! (Administrator)** console. This is detected up front and reported with a clear message +//! rather than failing deep inside `sc.exe`. +//! +//! `install` clears any registration at the OTHER scope first ([`install_at_scope`]) so a host +//! upgrading from a user-level install never runs two units racing for the same port, and +//! `uninstall --scope auto` sweeps BOTH scopes so nothing is left starting the node. //! //! The OS calls are behind the [`ServiceBackend`] trait so the clean-reinstall ORDER is //! unit-tested against a recording mock — CI never shells out to `sc`/`launchctl`/`systemctl` @@ -93,12 +106,178 @@ const REMOVAL_POLL_ATTEMPTS: u32 = 40; /// The interval between removal polls (see [`REMOVAL_POLL_ATTEMPTS`]). const REMOVAL_POLL_INTERVAL: Duration = Duration::from_millis(500); -/// Whether user-level (no-elevation) install is supported on this OS. Windows SCM -/// is system-only; systemd/launchd support a user domain. -#[cfg(windows)] -const PREFERS_USER_LEVEL: bool = false; -#[cfg(not(windows))] -const PREFERS_USER_LEVEL: bool = true; +// --------------------------------------------------------------------------------------------- +// Service SCOPE (#526): which OS scope a registration lives in, and how one is chosen. +// --------------------------------------------------------------------------------------------- + +/// The OS scope a service registration lives in. +/// +/// The two scopes are genuinely different registrations in different places, with different +/// survival properties: +/// +/// * [`ServiceScope::System`] — a machine-wide registration owned by the privileged principal: +/// a systemd **system** unit (`/etc/systemd/system/…`, `WantedBy=multi-user.target`), a launchd +/// **daemon** (`/Library/LaunchDaemons/…`, `system` domain), or a Windows SCM service. It starts +/// at BOOT, with **no logged-in user session** — the only scope that survives a reboot on a +/// headless host. Registering one requires root/Administrator. +/// * [`ServiceScope::User`] — a per-user registration: a systemd **user** unit +/// (`~/.config/systemd/user/…`) or a launchd **agent** (`gui/` domain). It needs no +/// elevation, runs as the installing user, and starts when that user's session/manager starts — +/// so on a headless host it may not come back after a reboot at all. Windows SCM has no +/// equivalent, so this scope simply does not exist there. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ServiceScope { + /// Machine-wide, privileged, boot-started (see the type doc). + System, + /// Per-user, no elevation required, session-started (see the type doc). + User, +} + +impl ServiceScope { + /// Whether this is the per-user (no-elevation) scope. + pub fn is_user(self) -> bool { + self == ServiceScope::User + } + + /// The lowercase wire/CLI spelling (`"system"` / `"user"`), used in `--json` output and prose. + pub fn as_str(self) -> &'static str { + match self { + ServiceScope::System => "system", + ServiceScope::User => "user", + } + } + + /// The OTHER scope — the one a stale registration from a previous install could be hiding in + /// (see [`install_at_scope`]). + pub fn other(self) -> Self { + match self { + ServiceScope::System => ServiceScope::User, + ServiceScope::User => ServiceScope::System, + } + } +} + +/// What the operator ASKED for on the command line (`--scope `). +/// +/// Distinct from [`ServiceScope`] — the resolved answer — because `auto` is not a scope: it is a +/// request to let the host decide ([`resolve_scope`]). Kept as the default so a caller that passes +/// no flag (including a dig-installer release predating #526) behaves exactly as before. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)] +pub enum ScopeChoice { + /// Let the host decide: system scope when running as root/elevated, user scope otherwise. + #[default] + Auto, + /// Force a machine-wide, boot-started registration (requires root/Administrator). + System, + /// Force a per-user registration (ignored on Windows, which has no user scope). + User, +} + +/// Resolve the scope to act on. **The one scope decision in the codebase**, and deliberately PURE: +/// every input is a parameter — no `cfg!`, no `geteuid`, no filesystem — so the complete decision +/// table is unit-tested on any host at any privilege level (a `#[cfg(unix)]`-only scope test would +/// be unfalsifiable on a Windows dev box). +/// +/// * `os_supports_user == false` (Windows SCM) ⇒ always [`ServiceScope::System`]: there is exactly +/// one scope, so even an explicit `--scope user` cannot be honoured. +/// * An explicit [`ScopeChoice::System`]/[`ScopeChoice::User`] is **authoritative** — never silently +/// overridden by the privilege level. (Whether the caller may actually register there is a +/// separate, loudly-reported question: [`ensure_privilege_for_scope`].) +/// * [`ScopeChoice::Auto`] follows privilege: root gets the reboot-surviving system registration an +/// elevated installer needs; an ordinary desktop user keeps the historical no-elevation user +/// registration. +pub fn resolve_scope(choice: ScopeChoice, os_supports_user: bool, is_root: bool) -> ServiceScope { + if !os_supports_user { + return ServiceScope::System; + } + match choice { + ScopeChoice::System => ServiceScope::System, + ScopeChoice::User => ServiceScope::User, + ScopeChoice::Auto if is_root => ServiceScope::System, + ScopeChoice::Auto => ServiceScope::User, + } +} + +/// The scopes `uninstall` must sweep, in the order to sweep them. +/// +/// An explicit choice removes exactly what was named. **`auto` sweeps BOTH scopes** (requested one +/// first) on a user-capable OS: an uninstall that silently leaves the other scope's registration +/// behind is the defect class where a "removed" node keeps starting at boot. Sweeping is safe +/// because each scope is PROBED before anything is deleted ([`remove_registration`]), so a scope +/// holding nothing is never written to. +pub fn uninstall_scopes( + choice: ScopeChoice, + os_supports_user: bool, + is_root: bool, +) -> Vec { + let requested = resolve_scope(choice, os_supports_user, is_root); + if !os_supports_user || choice != ScopeChoice::Auto { + return vec![requested]; + } + vec![requested, requested.other()] +} + +/// Whether this OS has a per-user service scope at all. Windows SCM has no per-user services; +/// systemd and launchd both do. The one `cfg!`-reading adapter that feeds the pure +/// [`resolve_scope`] decision. +fn host_supports_user_scope() -> bool { + !cfg!(windows) +} + +/// Whether this process is root (uid 0). Read via `id -u` rather than adding a `libc` dependency +/// for one call — the same approach [`launchd_domain_target`] already uses for the uid. Always +/// `false` off unix, where the answer never reaches a decision: Windows has no user scope, so +/// [`resolve_scope`] short-circuits to system and elevation is checked by the SCM gate +/// ([`is_elevated`]). +#[cfg(unix)] +fn host_is_root() -> bool { + unix_uid() == Some(0) +} +#[cfg(not(unix))] +fn host_is_root() -> bool { + false +} + +/// The effective uid via `id -u`, or `None` when it cannot be determined. +#[cfg(unix)] +fn unix_uid() -> Option { + std::process::Command::new("id") + .arg("-u") + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .and_then(|s| s.trim().parse::().ok()) +} + +/// Resolve `choice` against THIS host — the single place the pure [`resolve_scope`] decision meets +/// the real OS and privilege level. +fn host_scope(choice: ScopeChoice) -> ServiceScope { + resolve_scope(choice, host_supports_user_scope(), host_is_root()) +} + +/// Refuse a system-scope registration that the caller cannot actually make, with a message that +/// says what to do — rather than failing cryptically deep inside `systemctl`/`launchctl`, and +/// rather than silently downgrading to user scope (which on a headless host would not survive a +/// reboot: exactly the defect #526 fixes). PURE, so the policy is table-tested. +/// +/// Windows is exempt: it has no user scope, and its elevation requirement is reported by its own +/// SCM gate ([`is_elevated`]) with Windows-specific advice. +fn ensure_privilege_for_scope( + scope: ServiceScope, + os_supports_user: bool, + is_root: bool, +) -> io::Result<()> { + if !os_supports_user || scope.is_user() || is_root { + return Ok(()); + } + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "dig-node: registering a system-level (machine-wide, boot-started) service requires \ + root. Re-run with `sudo dig-node install --scope system`, or install at user scope \ + (`--scope user`) — noting that a user-scope service only starts with your login \ + session and so may not come back after a reboot on a headless host.", + )) +} // These recovery-action items back the Windows-only crash-restart path // ([`configure_windows_recovery`] + the note in [`install`]), but are deliberately kept @@ -249,6 +428,154 @@ pub fn reinstall( Ok(report) } +/// What a per-scope removal attempt did — the reporting unit for the cross-scope migration +/// ([`install_at_scope`]) and for `uninstall` ([`remove_registrations`]). +/// +/// `found` and `removed` are deliberately separate: "nothing was registered here" and "something +/// was registered here and is STILL registered" are different facts, and only the second is a +/// leftover an operator must know about. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScopeRemoval { + /// The scope this attempt addressed. + pub scope: ServiceScope, + /// A registration was found at this scope (so a removal was attempted). + pub found: bool, + /// The registration was successfully deregistered. + pub removed: bool, + /// Why the scope could not be probed or removed, when it could not be. `None` on success and + /// on a clean "nothing registered here". + pub error: Option, +} + +/// Deregister the service at ONE scope, best-effort, reporting exactly what happened. +/// +/// Never returns `Err`: the caller decides which combination of per-scope outcomes is fatal (a +/// stale other-scope registration is not; a requested scope left behind is). PROBES FIRST, so a +/// scope holding no registration is never written to — that is what makes sweeping the other scope +/// safe on every install. A probe that ERRORS is reported, not read as absence (fail closed). +pub fn remove_registration( + backend: &B, + scope: ServiceScope, +) -> ScopeRemoval { + let mut removal = ScopeRemoval { + scope, + found: false, + removed: false, + error: None, + }; + match backend.is_installed() { + Ok(false) => return removal, + Ok(true) => removal.found = true, + Err(e) => { + removal.error = Some(format!("could not determine whether it is registered: {e}")); + return removal; + } + } + // Best-effort stop first so nothing keeps holding the node's port past the deregistration. + let _ = backend.stop(); + match backend.delete() { + Ok(()) => removal.removed = true, + Err(e) => removal.error = Some(e.to_string()), + } + removal +} + +/// Register at the requested scope, first clearing any registration at the OTHER scope. +/// +/// **The other-scope sweep is a placement requirement, not a nicety.** A host upgrading from a +/// prior user-level install to a system-level one would otherwise end up with TWO registrations, +/// both starting a node bound to the same port; which one wins is a race, and the stale one can. +/// So the other scope is deregistered BEFORE the requested one is created — never after, which +/// would delete a registration the new one may share state with, and never left to the caller. +/// +/// The sweep is best-effort: an unprivileged install cannot delete a system unit, and that must be +/// REPORTED (in the returned [`ScopeRemoval`]) rather than fail an otherwise-good install. `other` +/// is `None` where the platform has only one scope (Windows). +pub fn install_at_scope( + target: &T, + other: Option<(&O, ServiceScope)>, + plan: &InstallPlan, +) -> io::Result<(ReinstallReport, Option)> { + let migration = other.map(|(backend, scope)| remove_registration(backend, scope)); + let report = reinstall(target, plan)?; + Ok((report, migration)) +} + +/// Sweep several scopes, in order, reporting each ([`remove_registration`]). +pub fn remove_registrations(targets: &[(&dyn ServiceBackend, ServiceScope)]) -> Vec { + targets + .iter() + .map(|(backend, scope)| remove_registration(*backend, *scope)) + .collect() +} + +/// Turn per-scope removal results into the `uninstall` [`Outcome`], failing LOUDLY on anything +/// less than a complete removal. +/// +/// * Any scope found-but-not-removed, or any scope whose state could not be determined ⇒ `Err`: +/// an uninstall that leaves a registration behind (or cannot tell) must never report success — +/// that is how a "removed" node keeps starting at boot. +/// * No scope held a registration ⇒ `Err(NotFound)`: there was nothing to uninstall. +/// * Otherwise ⇒ success, naming every scope removed. +fn uninstall_outcome(removals: Vec) -> io::Result { + let removed: Vec<&'static str> = removals + .iter() + .filter(|r| r.removed) + .map(|r| r.scope.as_str()) + .collect(); + let problems: Vec = removals + .iter() + .filter(|r| r.error.is_some() || (r.found && !r.removed)) + .map(|r| { + let why = r.error.as_deref().unwrap_or("removal did not take effect"); + format!("{} scope: {why}", r.scope.as_str()) + }) + .collect(); + + if !problems.is_empty() { + let removed_note = if removed.is_empty() { + "nothing was removed".to_string() + } else { + format!("removed at: {}", removed.join(", ")) + }; + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!( + "dig-node: could not fully uninstall service \"{SERVICE_LABEL}\" ({removed_note}). \ + Unresolved: {}. Re-run elevated (e.g. `sudo dig-node uninstall --scope system`) — \ + a registration left behind will keep starting the node.", + problems.join("; ") + ), + )); + } + if removed.is_empty() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + format!( + "dig-node: no service registration for \"{SERVICE_LABEL}\" was found at {} \ + scope — nothing to uninstall.", + removals + .iter() + .map(|r| r.scope.as_str()) + .collect::>() + .join(" or ") + ), + )); + } + Ok(Outcome::new( + format!( + "dig-node: uninstalled service \"{SERVICE_LABEL}\" at {} scope", + removed.join(" + ") + ), + json!({ + "installed": false, + "registered": false, + "label": SERVICE_LABEL, + "removed_scopes": removed, + }), + )) +} + /// Poll [`ServiceBackend::is_installed`] until the service is gone, bounded by /// [`REMOVAL_POLL_ATTEMPTS`]. Checks BEFORE sleeping, so a backend that removes synchronously /// (the test mock, and systemd/launchd) returns immediately with no delay; only a lingering @@ -410,22 +737,27 @@ fn insecure_service_target_allowed() -> bool { /// user who owns the binary — there is no privilege boundary to cross — so it is always allowed. /// `allow_insecure_override` is the explicit test/dev opt-out /// ([`ALLOW_INSECURE_SERVICE_TARGET_ENV`]); it is default-`false` in production. +/// +/// Since #526 this genuinely fires on unix too (a root systemd unit / launchd daemon is as +/// privileged as a Windows SYSTEM service), so its refusal NAMES the offending directory level: the +/// gate walks every ancestor, and an operator cannot act on "somewhere in this path is +/// user-writable". The canonical installer target (`/opt/dig/bin`, root-owned) clears it. fn ensure_service_target_is_safe( program: &std::path::Path, - user_level: bool, + scope: ServiceScope, allow_insecure_override: bool, ) -> io::Result<()> { // A user-level service runs as the installing user: swapping a binary that user already owns // grants that user nothing it lacked. No privilege boundary, no LPE — always allowed. - if user_level { + if scope.is_user() { return Ok(()); } // The program's directory is the surface an attacker would need write access to; a // privileged-owned directory keeps a non-privileged user from replacing the binary in it. let root = program.parent().unwrap_or(program); - if crate::security::dir_is_privileged(root) { + let Some(offending) = crate::security::first_unprivileged_ancestor(root) else { return Ok(()); - } + }; // Explicit, default-off test/dev opt-out — a controlled install of an unreleased build from a // build directory (see the env-var doc). Never set on an end-user machine. if allow_insecure_override { @@ -442,12 +774,13 @@ fn ensure_service_target_is_safe( io::ErrorKind::PermissionDenied, format!( "dig-node: refusing to register a system-level (privileged) service pointing at \ - \"{}\", whose directory is writable by a non-privileged user. Registering it would \ - let any local user replace that binary and gain persistent SYSTEM/root code \ + \"{}\": the path level \"{}\" is writable by a non-privileged user. Registering it \ + would let any local user replace that binary and gain persistent SYSTEM/root code \ execution (a privilege-escalation vector, #565). Install dig-node into a protected, \ admin-owned location — via the DIG installer or a native OS package — and re-run \ `dig-node install` from there.", - program.display() + program.display(), + offending.display(), ), )) } @@ -473,38 +806,53 @@ fn is_elevated() -> bool { true } -/// The real [`ServiceBackend`]: the native OS service manager (user-level on Linux/macOS, -/// system-level on Windows) plus the OS existence probe and the Windows display-name override -/// + read-back verify. +/// The real [`ServiceBackend`]: the native OS service manager pinned to ONE +/// [`ServiceScope`], plus the scope-aware OS existence probe and the Windows display-name +/// override + read-back verify. pub struct SystemServiceBackend { label: ServiceLabel, manager: Box, - /// Whether the manager is operating at user level (Linux/macOS) — surfaced for messaging. - user_level: bool, + /// The scope this backend acts on — every probe/create/delete addresses THAT scope only. + scope: ServiceScope, /// Windows-only: whether the post-create `sc qc` read-back confirmed the display name was /// actually applied. `None` off Windows (nothing to verify) or before a `create` has run. display_name_verified: Cell>, } impl SystemServiceBackend { - /// Acquire the native service manager, set to user-level where the platform supports it. - pub fn new() -> io::Result { + /// Acquire the native service manager pinned to `scope`. + /// + /// A scope the platform's manager cannot address is an ERROR naming the scope — never a silent + /// downgrade to the other one, which is how a requested boot-surviving registration would + /// quietly become a session-only one (#526). + pub fn new(scope: ServiceScope) -> io::Result { let mut manager = ::native()?; - let mut user_level = false; - if PREFERS_USER_LEVEL && manager.set_level(ServiceLevel::User).is_ok() { - user_level = true; - } + let level = if scope.is_user() { + ServiceLevel::User + } else { + ServiceLevel::System + }; + manager.set_level(level).map_err(|e| { + io::Error::new( + io::ErrorKind::Unsupported, + format!( + "dig-node: this platform's service manager cannot register a {}-level \ + service ({e})", + scope.as_str() + ), + ) + })?; Ok(Self { label: label()?, manager, - user_level, + scope, display_name_verified: Cell::new(None), }) } - /// Whether this backend installs at user level (no elevation) vs system level. - pub fn user_level(&self) -> bool { - self.user_level + /// The scope this backend acts on. + pub fn scope(&self) -> ServiceScope { + self.scope } /// Windows: whether the `sc qc` read-back confirmed the display-name override took effect. @@ -523,7 +871,10 @@ impl SystemServiceBackend { impl ServiceBackend for SystemServiceBackend { fn is_installed(&self) -> io::Result { - Ok(query_installed(&os_native_service_name(&self.label))) + Ok(query_installed( + &os_native_service_name(&self.label), + self.scope, + )) } fn stop(&self) -> io::Result<()> { @@ -586,10 +937,11 @@ fn os_native_service_name(label: &ServiceLabel) -> String { } } -/// Probe whether a service named `service_name` is registered, per OS. Best-effort: a probe -/// that cannot run (tool missing) reports `false` so the clean-reinstall proceeds to create. +/// Probe whether a service named `service_name` is registered AT `scope`, per OS. Best-effort: a +/// probe that cannot run (tool missing) reports `false` so the clean-reinstall proceeds to create. +/// Windows SCM has exactly one scope, so `scope` carries no information there. #[cfg(windows)] -fn query_installed(service_name: &str) -> bool { +fn query_installed(service_name: &str, _scope: ServiceScope) -> bool { // `sc query ` exits 0 when the service exists, 1060 (does-not-exist) otherwise. std::process::Command::new("sc.exe") .args(["query", service_name]) @@ -601,10 +953,10 @@ fn query_installed(service_name: &str) -> bool { } /// macOS launchd existence probe: `launchctl print /