Skip to main content

running_process/maintenance/
release_handles.rs

1//! `running-process maintenance release-handles --path <PATH>`.
2//!
3//! Phase 1 of #228 (issue #230). The goal is to make
4//! `rm -rf <PATH>` reliable on Windows even when a daemon process is
5//! holding handles inside `<PATH>` — see soldr#710.
6//!
7//! ## Per-platform behaviour
8//!
9//! - **POSIX**: no-op. Linux/macOS use delete-on-close semantics, so
10//!   `rm -rf` always succeeds; the subcommand exits 0 with an
11//!   informational message.
12//! - **Windows**: scaffold-only in Phase 1. The full handler depends
13//!   on the manifest registry that ships in Phase 2 (#231). Until
14//!   then the subcommand returns a successful "no manifests to scan
15//!   yet" result so downstream callers (clud-pr, soldr cleanup) can
16//!   start wiring the call site without changing their exit-code
17//!   handling later.
18//!
19//! ## Why ship the POSIX no-op now?
20//!
21//! Cross-platform tooling (soldr's clud-pr workflow, CI helpers) can
22//! call the subcommand unconditionally and get the right behaviour on
23//! every host. Without the no-op surface, every caller would need its
24//! own `cfg(unix)` short-circuit.
25
26use std::path::{Path, PathBuf};
27
28/// Errors emitted by [`run_release_handles`].
29#[derive(Debug, thiserror::Error)]
30pub enum ReleaseHandlesError {
31    /// The supplied `--path` argument was empty.
32    #[error("--path must be non-empty")]
33    EmptyPath,
34}
35
36/// Inputs used to authorize a future daemon-side `release-handles` request.
37///
38/// `requester_account_id` and `daemon_owner_account_id` are opaque OS account
39/// identifiers: UID strings on POSIX and SID strings on Windows. The helper
40/// keeps them opaque so tests can exercise the policy without needing a real
41/// cross-user setup.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub struct ReleaseHandlesAuthorization<'a> {
44    /// UID/SID of the client asking the daemon to release handles.
45    pub requester_account_id: &'a str,
46    /// UID/SID that owns the daemon holding the target handles.
47    pub daemon_owner_account_id: &'a str,
48    /// Whether the requester can write to the requested target path.
49    pub requester_can_write_target_path: bool,
50}
51
52/// Errors emitted by [`authorize_release_handles_request`].
53#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
54pub enum ReleaseHandlesAuthorizationError {
55    /// The requester identity was empty.
56    #[error("release-handles requester identity must be non-empty")]
57    EmptyRequesterIdentity,
58    /// The daemon owner identity was empty.
59    #[error("release-handles daemon owner identity must be non-empty")]
60    EmptyDaemonOwnerIdentity,
61    /// The requester UID/SID did not match the daemon owner's UID/SID.
62    #[error("release-handles requester identity does not match daemon owner")]
63    OwnerMismatch,
64    /// The requester did not have write access to the requested target path.
65    #[error("release-handles requester lacks write access to target path")]
66    TargetPathWriteDenied,
67}
68
69/// Authorize a daemon-side `release-handles` request.
70///
71/// The policy intentionally checks both ownership and target-path write access:
72/// a requester must be the same OS account that owns the daemon and must be
73/// able to write to the path it is asking the daemon to free.
74pub fn authorize_release_handles_request(
75    authorization: ReleaseHandlesAuthorization<'_>,
76) -> Result<(), ReleaseHandlesAuthorizationError> {
77    if authorization.requester_account_id.trim().is_empty() {
78        return Err(ReleaseHandlesAuthorizationError::EmptyRequesterIdentity);
79    }
80    if authorization.daemon_owner_account_id.trim().is_empty() {
81        return Err(ReleaseHandlesAuthorizationError::EmptyDaemonOwnerIdentity);
82    }
83    if authorization.requester_account_id != authorization.daemon_owner_account_id {
84        return Err(ReleaseHandlesAuthorizationError::OwnerMismatch);
85    }
86    if !authorization.requester_can_write_target_path {
87        return Err(ReleaseHandlesAuthorizationError::TargetPathWriteDenied);
88    }
89
90    Ok(())
91}
92
93/// Result of one `release-handles` invocation.
94///
95/// Stable across Phase 1 → Phase 2 — Phase 2 will populate the
96/// `manifests_scanned` / `handles_released` counters that are zero in
97/// the Phase 1 stub.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct ReleaseHandlesOutcome {
100    /// The path the caller asked us to free up.
101    pub path: PathBuf,
102    /// Informational human-readable message — printed verbatim by the
103    /// CLI when `--json` is not set.
104    pub message: String,
105    /// Number of manifests we walked. Always 0 in Phase 1.
106    pub manifests_scanned: u32,
107    /// Number of handle-drop requests we issued. Always 0 in Phase 1.
108    pub handles_released: u32,
109    /// `true` when no further action is needed (POSIX always returns
110    /// `true`; Windows-Phase-1 returns `true` because the manifest
111    /// registry doesn't exist yet).
112    pub already_clean: bool,
113}
114
115impl ReleaseHandlesOutcome {
116    /// Render as a JSON object. Stable shape across phases — Phase 2
117    /// adds counter values but never adds or removes top-level keys.
118    pub fn to_json(&self) -> String {
119        // Hand-roll the JSON to avoid pulling serde_json into a code
120        // path that needs to be cross-platform clean. Field order is
121        // chosen for grep-ability.
122        format!(
123            "{{\
124\"path\":\"{path}\",\
125\"manifests_scanned\":{manifests},\
126\"handles_released\":{handles},\
127\"already_clean\":{clean},\
128\"message\":\"{message}\"\
129}}",
130            path = json_escape(&self.path.to_string_lossy()),
131            manifests = self.manifests_scanned,
132            handles = self.handles_released,
133            clean = self.already_clean,
134            message = json_escape(&self.message),
135        )
136    }
137}
138
139/// Run the `release-handles` subcommand. Cross-platform entrypoint
140/// called by `runpm maintenance release-handles`.
141pub fn run_release_handles(path: &Path) -> Result<ReleaseHandlesOutcome, ReleaseHandlesError> {
142    let path_str = path.to_string_lossy();
143    if path_str.trim().is_empty() {
144        return Err(ReleaseHandlesError::EmptyPath);
145    }
146
147    #[cfg(unix)]
148    {
149        Ok(ReleaseHandlesOutcome {
150            path: path.to_path_buf(),
151            message: format!(
152                "POSIX delete-on-close semantics make this a no-op; proceed with `rm -rf {path_str}`"
153            ),
154            manifests_scanned: 0,
155            handles_released: 0,
156            already_clean: true,
157        })
158    }
159
160    #[cfg(windows)]
161    {
162        // Phase 1 stub. Phase 2 (#231) ships the manifest registry
163        // under `%LOCALAPPDATA%\running-process\manifests\` and the
164        // full handler will enumerate that directory + send
165        // `MaintenanceRequest::ReleaseHandles { path_prefix }` over
166        // each live daemon's pipe. For now we return a successful
167        // "nothing to do" result so callers can wire the integration
168        // unconditionally.
169        Ok(ReleaseHandlesOutcome {
170            path: path.to_path_buf(),
171            message: format!(
172                "Phase 2 manifest registry not yet shipped; no daemons to query for handles under \
173                 {path_str}. Proceed with rm -rf and report soldr#710 reproductions if encountered."
174            ),
175            manifests_scanned: 0,
176            handles_released: 0,
177            already_clean: true,
178        })
179    }
180}
181
182fn json_escape(s: &str) -> String {
183    let mut out = String::with_capacity(s.len());
184    for c in s.chars() {
185        match c {
186            '"' => out.push_str("\\\""),
187            '\\' => out.push_str("\\\\"),
188            '\n' => out.push_str("\\n"),
189            '\r' => out.push_str("\\r"),
190            '\t' => out.push_str("\\t"),
191            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
192            c => out.push(c),
193        }
194    }
195    out
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn empty_path_returns_error() {
204        let err = run_release_handles(Path::new("")).unwrap_err();
205        match err {
206            ReleaseHandlesError::EmptyPath => {}
207        }
208    }
209
210    #[test]
211    fn non_empty_path_returns_ok() {
212        let outcome = run_release_handles(Path::new("/tmp/example")).expect("ok");
213        assert_eq!(outcome.path, PathBuf::from("/tmp/example"));
214        assert!(outcome.already_clean);
215        assert_eq!(outcome.manifests_scanned, 0);
216        assert_eq!(outcome.handles_released, 0);
217    }
218
219    #[test]
220    fn json_output_has_stable_keys() {
221        let outcome = run_release_handles(Path::new("/tmp/example")).expect("ok");
222        let json = outcome.to_json();
223        assert!(json.contains("\"path\":"));
224        assert!(json.contains("\"manifests_scanned\":"));
225        assert!(json.contains("\"handles_released\":"));
226        assert!(json.contains("\"already_clean\":"));
227        assert!(json.contains("\"message\":"));
228    }
229}