Skip to main content

rch_common/
force_resync.rs

1//! Agent-safe force-resync for stale path-dependency roots
2//! (bd-session-history-remediation-ocv9i.8.3).
3//!
4//! When stale dependency roots block verification, an agent needs ONE supported
5//! command that invalidates the worker-side cache for those roots, resyncs the
6//! closure, and explains what changed — *without ever deleting worker data
7//! outside an RCH-managed root*. This module plans that invalidation
8//! ([`plan_force_resync`]) with a hard safety guard (every target must live
9//! strictly under the RCH-managed base, and the base itself can never be the
10//! target), and reports the outcome ([`apply_force_resync`]) — deferring
11//! safely, with no destructive action, when the worker is unreachable.
12
13use std::path::{Path, PathBuf};
14
15use serde::{Deserialize, Serialize};
16
17/// A stale root to force-resync: its local project root and the worker-side
18/// RCH-managed cache path that should be invalidated.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct StaleRoot {
21    pub local_root: PathBuf,
22    pub worker_cache_path: PathBuf,
23}
24
25/// A planned, safety-checked cache invalidation.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct InvalidationAction {
28    pub local_root: PathBuf,
29    pub worker_cache_path: PathBuf,
30}
31
32/// A refused invalidation (its target would escape the RCH-managed root).
33#[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/// A safety-checked force-resync plan.
41#[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    /// True when at least one root was refused on safety grounds.
50    #[must_use]
51    pub fn has_refusals(&self) -> bool {
52        !self.refused.is_empty()
53    }
54}
55
56/// Whether `target` is a *safe* invalidation target under `managed_base`: it
57/// must be strictly inside the base (never the base itself, never an ancestor,
58/// never an escaping `..` path).
59#[must_use]
60pub fn is_safe_invalidation_target(target: &Path, managed_base: &Path) -> bool {
61    // Reject any traversal component up front; we operate on declared paths.
62    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/// Plan the force-resync: each stale root whose worker cache path is safely
72/// under `managed_base` becomes an invalidation; any other is refused (never
73/// invalidated) so no worker data outside the managed root is ever touched.
74#[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/// Outcome of applying a force-resync plan.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "snake_case")]
104pub enum ResyncOutcome {
105    /// Invalidations were applied and a closure resync should follow.
106    Applied,
107    /// The worker was unreachable — deferred with NO destructive action.
108    DeferredWorkerUnavailable,
109}
110
111/// The agent-facing report of a force-resync.
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113pub struct ForceResyncReport {
114    pub outcome: ResyncOutcome,
115    /// Local roots that were stale (named so the agent sees the original cause).
116    pub previously_stale_roots: Vec<PathBuf>,
117    /// Worker cache paths that were invalidated (empty when deferred).
118    pub invalidated: Vec<PathBuf>,
119    /// Roots refused on safety grounds.
120    pub refused: Vec<RefusedInvalidation>,
121    pub detail: String,
122}
123
124impl ForceResyncReport {
125    /// Whether anything was actually invalidated.
126    #[must_use]
127    pub fn did_invalidate(&self) -> bool {
128        !self.invalidated.is_empty()
129    }
130
131    /// Human-readable explanation of what changed.
132    #[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/// Apply a plan, gated on worker reachability. When the worker is unreachable
157/// the resync is deferred and NO invalidation is reported (no destructive
158/// action is taken). The actual `rm` + resync transfer is performed by the
159/// caller using `report.invalidated`; this models the safe decision + report.
160#[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        // The base itself is never a target (would wipe everything).
220        assert!(!is_safe_invalidation_target(base, base));
221        // Outside the base.
222        assert!(!is_safe_invalidation_target(
223            Path::new("/home/user/project"),
224            base
225        ));
226        // Traversal escape.
227        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"), // escapes the managed base
238            stale("/data/projects/c", BASE),           // == base, must refuse
239        ];
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        // Forced resync after a root went stale: it is invalidated and the
253        // report names the previously-stale local root.
254        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        // The previously-stale root is still named for the agent.
287        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        // Even applied, nothing outside the managed base was invalidated.
302        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}