1use std::path::{Path, PathBuf};
14
15use serde::{Deserialize, Serialize};
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct StaleRoot {
21 pub local_root: PathBuf,
22 pub worker_cache_path: PathBuf,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct InvalidationAction {
28 pub local_root: PathBuf,
29 pub worker_cache_path: PathBuf,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct RefusedInvalidation {
35 pub local_root: PathBuf,
36 pub worker_cache_path: PathBuf,
37 pub reason: String,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct ForceResyncPlan {
43 pub managed_base: PathBuf,
44 pub invalidations: Vec<InvalidationAction>,
45 pub refused: Vec<RefusedInvalidation>,
46}
47
48impl ForceResyncPlan {
49 #[must_use]
51 pub fn has_refusals(&self) -> bool {
52 !self.refused.is_empty()
53 }
54}
55
56#[must_use]
60pub fn is_safe_invalidation_target(target: &Path, managed_base: &Path) -> bool {
61 if target
63 .components()
64 .any(|c| matches!(c, std::path::Component::ParentDir))
65 {
66 return false;
67 }
68 target.starts_with(managed_base) && target != managed_base
69}
70
71#[must_use]
75pub fn plan_force_resync(stale: &[StaleRoot], managed_base: &Path) -> ForceResyncPlan {
76 let mut invalidations = Vec::new();
77 let mut refused = Vec::new();
78 for root in stale {
79 if is_safe_invalidation_target(&root.worker_cache_path, managed_base) {
80 invalidations.push(InvalidationAction {
81 local_root: root.local_root.clone(),
82 worker_cache_path: root.worker_cache_path.clone(),
83 });
84 } else {
85 refused.push(RefusedInvalidation {
86 local_root: root.local_root.clone(),
87 worker_cache_path: root.worker_cache_path.clone(),
88 reason:
89 "target is not strictly inside the RCH-managed base; refusing to invalidate"
90 .to_string(),
91 });
92 }
93 }
94 ForceResyncPlan {
95 managed_base: managed_base.to_path_buf(),
96 invalidations,
97 refused,
98 }
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "snake_case")]
104pub enum ResyncOutcome {
105 Applied,
107 DeferredWorkerUnavailable,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113pub struct ForceResyncReport {
114 pub outcome: ResyncOutcome,
115 pub previously_stale_roots: Vec<PathBuf>,
117 pub invalidated: Vec<PathBuf>,
119 pub refused: Vec<RefusedInvalidation>,
121 pub detail: String,
122}
123
124impl ForceResyncReport {
125 #[must_use]
127 pub fn did_invalidate(&self) -> bool {
128 !self.invalidated.is_empty()
129 }
130
131 #[must_use]
133 pub fn render(&self) -> String {
134 let mut s = format!(
135 "force-resync: {:?} — {} stale root(s), {} invalidated, {} refused\n {}",
136 self.outcome,
137 self.previously_stale_roots.len(),
138 self.invalidated.len(),
139 self.refused.len(),
140 self.detail,
141 );
142 for r in &self.previously_stale_roots {
143 s.push_str(&format!("\n stale: {}", r.display()));
144 }
145 for r in &self.refused {
146 s.push_str(&format!(
147 "\n REFUSED: {} ({})",
148 r.worker_cache_path.display(),
149 r.reason
150 ));
151 }
152 s
153 }
154}
155
156#[must_use]
161pub fn apply_force_resync(plan: &ForceResyncPlan, worker_reachable: bool) -> ForceResyncReport {
162 let previously_stale_roots: Vec<PathBuf> = plan
163 .invalidations
164 .iter()
165 .map(|a| a.local_root.clone())
166 .chain(plan.refused.iter().map(|r| r.local_root.clone()))
167 .collect();
168
169 if !worker_reachable {
170 return ForceResyncReport {
171 outcome: ResyncOutcome::DeferredWorkerUnavailable,
172 previously_stale_roots,
173 invalidated: Vec::new(),
174 refused: plan.refused.clone(),
175 detail: "worker unavailable; force-resync deferred (no destructive action taken)"
176 .to_string(),
177 };
178 }
179
180 let invalidated: Vec<PathBuf> = plan
181 .invalidations
182 .iter()
183 .map(|a| a.worker_cache_path.clone())
184 .collect();
185 let detail = format!(
186 "invalidated {} RCH-managed cache path(s) under {}; closure will resync",
187 invalidated.len(),
188 plan.managed_base.display(),
189 );
190 ForceResyncReport {
191 outcome: ResyncOutcome::Applied,
192 previously_stale_roots,
193 invalidated,
194 refused: plan.refused.clone(),
195 detail,
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202
203 fn stale(local: &str, cache: &str) -> StaleRoot {
204 StaleRoot {
205 local_root: PathBuf::from(local),
206 worker_cache_path: PathBuf::from(cache),
207 }
208 }
209
210 const BASE: &str = "/data/tmp/rch-sync";
211
212 #[test]
213 fn safe_target_must_be_strictly_inside_base() {
214 let base = Path::new(BASE);
215 assert!(is_safe_invalidation_target(
216 Path::new("/data/tmp/rch-sync/proj-a"),
217 base
218 ));
219 assert!(!is_safe_invalidation_target(base, base));
221 assert!(!is_safe_invalidation_target(
223 Path::new("/home/user/project"),
224 base
225 ));
226 assert!(!is_safe_invalidation_target(
228 Path::new("/data/tmp/rch-sync/../../etc"),
229 base
230 ));
231 }
232
233 #[test]
234 fn plan_invalidates_managed_and_refuses_escaping() {
235 let stale_roots = vec![
236 stale("/data/projects/a", "/data/tmp/rch-sync/proj-a"),
237 stale("/data/projects/b", "/home/user/b"), stale("/data/projects/c", BASE), ];
240 let plan = plan_force_resync(&stale_roots, Path::new(BASE));
241 assert_eq!(plan.invalidations.len(), 1);
242 assert_eq!(
243 plan.invalidations[0].local_root,
244 PathBuf::from("/data/projects/a")
245 );
246 assert_eq!(plan.refused.len(), 2);
247 assert!(plan.has_refusals());
248 }
249
250 #[test]
251 fn forced_resync_after_stale_applies_and_names_root() {
252 let plan = plan_force_resync(
255 &[stale("/data/projects/a", "/data/tmp/rch-sync/proj-a")],
256 Path::new(BASE),
257 );
258 let report = apply_force_resync(&plan, true);
259 assert_eq!(report.outcome, ResyncOutcome::Applied);
260 assert!(report.did_invalidate());
261 assert_eq!(
262 report.invalidated,
263 vec![PathBuf::from("/data/tmp/rch-sync/proj-a")]
264 );
265 assert!(
266 report
267 .previously_stale_roots
268 .contains(&PathBuf::from("/data/projects/a"))
269 );
270 assert!(report.render().contains("/data/projects/a"));
271 }
272
273 #[test]
274 fn force_resync_with_unavailable_worker_defers_safely() {
275 let plan = plan_force_resync(
276 &[stale("/data/projects/a", "/data/tmp/rch-sync/proj-a")],
277 Path::new(BASE),
278 );
279 let report = apply_force_resync(&plan, false);
280 assert_eq!(report.outcome, ResyncOutcome::DeferredWorkerUnavailable);
281 assert!(
282 !report.did_invalidate(),
283 "deferred must not invalidate anything"
284 );
285 assert!(report.invalidated.is_empty());
286 assert!(
288 report
289 .previously_stale_roots
290 .contains(&PathBuf::from("/data/projects/a"))
291 );
292 assert!(report.detail.contains("no destructive action"));
293 }
294
295 #[test]
296 fn refused_roots_are_surfaced_in_report() {
297 let plan = plan_force_resync(&[stale("/data/projects/b", "/etc/passwd")], Path::new(BASE));
298 let report = apply_force_resync(&plan, true);
299 assert_eq!(report.refused.len(), 1);
300 assert!(report.render().contains("REFUSED"));
301 assert!(report.invalidated.is_empty());
303 }
304
305 #[test]
306 fn report_serde_roundtrip() {
307 let plan = plan_force_resync(
308 &[stale("/data/projects/a", "/data/tmp/rch-sync/proj-a")],
309 Path::new(BASE),
310 );
311 let report = apply_force_resync(&plan, true);
312 let back: ForceResyncReport =
313 serde_json::from_str(&serde_json::to_string(&report).unwrap()).unwrap();
314 assert_eq!(report, back);
315 }
316}