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}