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    Ok(outcome_for(
148        path,
149        crate::platform::fs::open_handles_block_removal(),
150    ))
151}
152
153/// The outcome for `path` on a host where open handles do (Windows) or do
154/// not (POSIX delete-on-close) keep a file from being removed.
155fn outcome_for(path: &Path, open_handles_block_removal: bool) -> ReleaseHandlesOutcome {
156    let path_str = path.to_string_lossy();
157    if !open_handles_block_removal {
158        ReleaseHandlesOutcome {
159            path: path.to_path_buf(),
160            message: format!(
161                "POSIX delete-on-close semantics make this a no-op; proceed with `rm -rf {path_str}`"
162            ),
163            manifests_scanned: 0,
164            handles_released: 0,
165            already_clean: true,
166        }
167    } else {
168        // Phase 1 stub. Phase 2 (#231) ships the manifest registry
169        // under `%LOCALAPPDATA%\running-process\manifests\` and the
170        // full handler will enumerate that directory + send
171        // `MaintenanceRequest::ReleaseHandles { path_prefix }` over
172        // each live daemon's pipe. For now we return a successful
173        // "nothing to do" result so callers can wire the integration
174        // unconditionally.
175        ReleaseHandlesOutcome {
176            path: path.to_path_buf(),
177            message: format!(
178                "Phase 2 manifest registry not yet shipped; no daemons to query for handles under \
179                 {path_str}. Proceed with rm -rf and report soldr#710 reproductions if encountered."
180            ),
181            manifests_scanned: 0,
182            handles_released: 0,
183            already_clean: true,
184        }
185    }
186}
187
188fn json_escape(s: &str) -> String {
189    let mut out = String::with_capacity(s.len());
190    for c in s.chars() {
191        match c {
192            '"' => out.push_str("\\\""),
193            '\\' => out.push_str("\\\\"),
194            '\n' => out.push_str("\\n"),
195            '\r' => out.push_str("\\r"),
196            '\t' => out.push_str("\\t"),
197            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
198            c => out.push(c),
199        }
200    }
201    out
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn empty_path_returns_error() {
210        let err = run_release_handles(Path::new("")).unwrap_err();
211        match err {
212            ReleaseHandlesError::EmptyPath => {}
213        }
214    }
215
216    #[test]
217    fn non_empty_path_returns_ok() {
218        let outcome = run_release_handles(Path::new("/tmp/example")).expect("ok");
219        assert_eq!(outcome.path, PathBuf::from("/tmp/example"));
220        assert!(outcome.already_clean);
221        assert_eq!(outcome.manifests_scanned, 0);
222        assert_eq!(outcome.handles_released, 0);
223    }
224
225    /// Both host messages are pinned byte-for-byte on every host.
226    #[test]
227    fn outcome_messages_are_pinned_for_both_host_semantics() {
228        let path = Path::new("/w/tree");
229        let posix = outcome_for(path, false);
230        assert_eq!(
231            posix.message,
232            "POSIX delete-on-close semantics make this a no-op; proceed with `rm -rf /w/tree`"
233        );
234        let windows = outcome_for(path, true);
235        assert_eq!(
236            windows.message,
237            "Phase 2 manifest registry not yet shipped; no daemons to query for handles under \
238             /w/tree. Proceed with rm -rf and report soldr#710 reproductions if encountered."
239        );
240        for outcome in [posix, windows] {
241            assert_eq!(outcome.path, PathBuf::from("/w/tree"));
242            assert_eq!(outcome.manifests_scanned, 0);
243            assert_eq!(outcome.handles_released, 0);
244            assert!(outcome.already_clean);
245        }
246    }
247
248    #[test]
249    fn json_output_has_stable_keys() {
250        let outcome = run_release_handles(Path::new("/tmp/example")).expect("ok");
251        let json = outcome.to_json();
252        assert!(json.contains("\"path\":"));
253        assert!(json.contains("\"manifests_scanned\":"));
254        assert!(json.contains("\"handles_released\":"));
255        assert!(json.contains("\"already_clean\":"));
256        assert!(json.contains("\"message\":"));
257    }
258}