running_process/maintenance/
release_handles.rs1use std::path::{Path, PathBuf};
27
28#[derive(Debug, thiserror::Error)]
30pub enum ReleaseHandlesError {
31 #[error("--path must be non-empty")]
33 EmptyPath,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub struct ReleaseHandlesAuthorization<'a> {
44 pub requester_account_id: &'a str,
46 pub daemon_owner_account_id: &'a str,
48 pub requester_can_write_target_path: bool,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
54pub enum ReleaseHandlesAuthorizationError {
55 #[error("release-handles requester identity must be non-empty")]
57 EmptyRequesterIdentity,
58 #[error("release-handles daemon owner identity must be non-empty")]
60 EmptyDaemonOwnerIdentity,
61 #[error("release-handles requester identity does not match daemon owner")]
63 OwnerMismatch,
64 #[error("release-handles requester lacks write access to target path")]
66 TargetPathWriteDenied,
67}
68
69pub 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#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct ReleaseHandlesOutcome {
100 pub path: PathBuf,
102 pub message: String,
105 pub manifests_scanned: u32,
107 pub handles_released: u32,
109 pub already_clean: bool,
113}
114
115impl ReleaseHandlesOutcome {
116 pub fn to_json(&self) -> String {
119 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
139pub 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
153fn 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 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 #[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}