Skip to main content

rch_common/
artifact_pattern.rs

1//! Target-dir-aware artifact pattern rewriting
2//! (bd-session-history-remediation-ocv9i.9.1).
3//!
4//! RCH rewrites `CARGO_TARGET_DIR` on the worker (to `.rch-target`, a pooled
5//! root, or a worker-scoped dir) so concurrent builds don't collide. But the
6//! artifact *patterns* an agent (or the classifier) specifies are written
7//! against the *original* target dir — so retrieval looks in the wrong place
8//! and reports a spurious "artifact miss". [`rewrite_artifact_pattern`] maps a
9//! pattern from the original target dir to the effective one, and
10//! [`ArtifactRetrievalDiagnostics`] records the original pattern, the effective
11//! pattern, the target dir, and the matched files so the rewrite is auditable.
12
13use serde::{Deserialize, Serialize};
14
15use crate::incident::IncidentReasonCode;
16
17/// The result of mapping one artifact pattern across a target-dir rewrite.
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct ArtifactPatternRewrite {
20    /// The pattern as originally specified.
21    pub original_pattern: String,
22    /// The pattern after rewriting to the effective target dir.
23    pub effective_pattern: String,
24    /// The original `CARGO_TARGET_DIR` the pattern was written against.
25    pub original_target_dir: String,
26    /// The effective `CARGO_TARGET_DIR` the build actually used.
27    pub effective_target_dir: String,
28    /// Whether the pattern was actually rewritten (false when it did not
29    /// reference the target dir, or the dirs are identical).
30    pub rewritten: bool,
31}
32
33/// Rewrite `pattern` from `original_target_dir` to `effective_target_dir`.
34///
35/// A pattern is rewritten only when it begins with the original target dir on a
36/// path-segment boundary (so `target` rewrites but `target_foo` does not). Both
37/// absolute (`/proj/target/...`) and relative (`target/...`) forms are handled;
38/// the caller passes matching forms. Patterns that do not reference the target
39/// dir pass through unchanged.
40#[must_use]
41pub fn rewrite_artifact_pattern(
42    pattern: &str,
43    original_target_dir: &str,
44    effective_target_dir: &str,
45) -> ArtifactPatternRewrite {
46    let unchanged = |rewritten: bool, effective: String| ArtifactPatternRewrite {
47        original_pattern: pattern.to_string(),
48        effective_pattern: effective,
49        original_target_dir: original_target_dir.to_string(),
50        effective_target_dir: effective_target_dir.to_string(),
51        rewritten,
52    };
53
54    if original_target_dir.is_empty() || original_target_dir == effective_target_dir {
55        return unchanged(false, pattern.to_string());
56    }
57
58    // Trim a single trailing slash so `target/` and `target` both match.
59    let orig = original_target_dir
60        .strip_suffix('/')
61        .unwrap_or(original_target_dir);
62
63    if let Some(rest) = pattern.strip_prefix(orig)
64        && (rest.is_empty() || rest.starts_with('/'))
65    {
66        let eff = effective_target_dir
67            .strip_suffix('/')
68            .unwrap_or(effective_target_dir);
69        return unchanged(true, format!("{eff}{rest}"));
70    }
71
72    unchanged(false, pattern.to_string())
73}
74
75/// Recognized artifact output roots (relative to the project). A retrieved
76/// artifact must land under one of these — never over tracked source.
77pub const ARTIFACT_OUTPUT_ROOTS: &[&str] = &["target", ".rch-target"];
78
79/// Whether a retrieved artifact's RELATIVE destination is safe to write into the
80/// local project. Protects against a malicious or stale remote layout
81/// overwriting local source: the destination must not escape the project root
82/// (no absolute path, no `..` traversal — the top-level root protection) and
83/// must land under a recognized artifact output root (`target` or a
84/// `.rch-target*` worker-scoped dir), never under `src/` or the project root
85/// itself.
86#[must_use]
87pub fn artifact_dest_is_safe(rel_dest: &str) -> bool {
88    if rel_dest.is_empty() || rel_dest.starts_with('/') {
89        return false;
90    }
91    if std::path::Path::new(rel_dest)
92        .components()
93        .any(|c| matches!(c, std::path::Component::ParentDir))
94    {
95        return false;
96    }
97    let first = rel_dest.split('/').next().unwrap_or("");
98    // `.rch-target`, `.rch-target-worker-css`, … all start with ".rch-target".
99    first == "target" || first.starts_with(".rch-target")
100}
101
102/// Auditable record of an artifact-retrieval attempt under a (possibly
103/// rewritten) target dir. Printed by retrieval diagnostics and surfaced in the
104/// JSON envelope.
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct ArtifactRetrievalDiagnostics {
107    /// Pattern as specified by the agent/classifier.
108    pub original_pattern: String,
109    /// Pattern actually used for retrieval (post-rewrite).
110    pub effective_pattern: String,
111    /// Effective remote target dir the build used.
112    pub target_dir: String,
113    /// Files matched by the effective pattern (relative paths).
114    pub matched_files: Vec<String>,
115}
116
117impl ArtifactRetrievalDiagnostics {
118    /// Build from a rewrite plus the files the effective pattern matched.
119    #[must_use]
120    pub fn new(rewrite: &ArtifactPatternRewrite, matched_files: Vec<String>) -> Self {
121        Self {
122            original_pattern: rewrite.original_pattern.clone(),
123            effective_pattern: rewrite.effective_pattern.clone(),
124            target_dir: rewrite.effective_target_dir.clone(),
125            matched_files,
126        }
127    }
128
129    /// True when no file matched — an artifact miss.
130    #[must_use]
131    pub fn is_miss(&self) -> bool {
132        self.matched_files.is_empty()
133    }
134
135    /// Incident reason for a miss (for the incident ledger / status surfaces).
136    #[must_use]
137    pub fn miss_reason(&self) -> Option<IncidentReasonCode> {
138        self.is_miss().then_some(IncidentReasonCode::ArtifactMiss)
139    }
140
141    /// Human-readable one-block diagnostic (the bead's required output: original
142    /// pattern, effective pattern, target dir, matched files).
143    #[must_use]
144    pub fn render(&self) -> String {
145        let mut s = format!(
146            "artifact retrieval:\n  original pattern: {}\n  effective pattern: {}\n  target dir: {}\n  matched files: {}",
147            self.original_pattern,
148            self.effective_pattern,
149            self.target_dir,
150            self.matched_files.len(),
151        );
152        for f in &self.matched_files {
153            s.push_str("\n    - ");
154            s.push_str(f);
155        }
156        if self.is_miss() {
157            s.push_str("\n  -> ARTIFACT MISS (no files matched under the rewritten target dir)");
158        }
159        s
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn relative_target_pattern_is_rewritten() {
169        let r = rewrite_artifact_pattern("target/debug/libfoo.rlib", "target", ".rch-target");
170        assert!(r.rewritten);
171        assert_eq!(r.effective_pattern, ".rch-target/debug/libfoo.rlib");
172    }
173
174    #[test]
175    fn absolute_target_pattern_is_rewritten() {
176        let r = rewrite_artifact_pattern(
177            "/proj/target/debug/app",
178            "/proj/target",
179            "/proj/.rch-target-worker-css",
180        );
181        assert!(r.rewritten);
182        assert_eq!(
183            r.effective_pattern,
184            "/proj/.rch-target-worker-css/debug/app"
185        );
186    }
187
188    #[test]
189    fn trailing_slashes_are_tolerated() {
190        let r = rewrite_artifact_pattern("target/x", "target/", ".rch-target/");
191        assert_eq!(r.effective_pattern, ".rch-target/x");
192        // The bare target dir itself also rewrites.
193        let bare = rewrite_artifact_pattern("target", "target", ".rch-target");
194        assert_eq!(bare.effective_pattern, ".rch-target");
195        assert!(bare.rewritten);
196    }
197
198    #[test]
199    fn segment_boundary_prevents_false_prefix_match() {
200        // "target_archive" must NOT be rewritten by an orig of "target".
201        let r = rewrite_artifact_pattern("target_archive/x", "target", ".rch-target");
202        assert!(!r.rewritten);
203        assert_eq!(r.effective_pattern, "target_archive/x");
204    }
205
206    #[test]
207    fn non_target_pattern_passes_through() {
208        let r = rewrite_artifact_pattern("dist/bundle.js", "target", ".rch-target");
209        assert!(!r.rewritten);
210        assert_eq!(r.effective_pattern, "dist/bundle.js");
211    }
212
213    #[test]
214    fn identical_dirs_are_a_noop() {
215        let r = rewrite_artifact_pattern("target/debug/x", "target", "target");
216        assert!(!r.rewritten);
217        assert_eq!(r.effective_pattern, "target/debug/x");
218    }
219
220    #[test]
221    fn empty_original_is_a_noop() {
222        let r = rewrite_artifact_pattern("target/x", "", ".rch-target");
223        assert!(!r.rewritten);
224    }
225
226    #[test]
227    fn diagnostics_record_match_and_render() {
228        let r = rewrite_artifact_pattern("target/debug/app", "target", ".rch-target");
229        let d = ArtifactRetrievalDiagnostics::new(&r, vec![".rch-target/debug/app".to_string()]);
230        assert!(!d.is_miss());
231        assert_eq!(d.miss_reason(), None);
232        assert_eq!(d.effective_pattern, ".rch-target/debug/app");
233        assert_eq!(d.target_dir, ".rch-target");
234        let text = d.render();
235        assert!(text.contains("original pattern: target/debug/app"));
236        assert!(text.contains("effective pattern: .rch-target/debug/app"));
237        assert!(text.contains(".rch-target/debug/app"));
238    }
239
240    #[test]
241    fn diagnostics_flag_artifact_miss() {
242        // The classic failure: build succeeded only under the rewritten target
243        // dir, but retrieval used the original pattern and matched nothing.
244        let r = rewrite_artifact_pattern("target/debug/app", "target", ".rch-target");
245        let d = ArtifactRetrievalDiagnostics::new(&r, vec![]);
246        assert!(d.is_miss());
247        assert_eq!(d.miss_reason(), Some(IncidentReasonCode::ArtifactMiss));
248        assert!(d.render().contains("ARTIFACT MISS"));
249    }
250
251    #[test]
252    fn diagnostics_serde_roundtrip() {
253        let r = rewrite_artifact_pattern("target/x", "target", ".rch-target");
254        let d = ArtifactRetrievalDiagnostics::new(&r, vec![".rch-target/x".to_string()]);
255        let back: ArtifactRetrievalDiagnostics =
256            serde_json::from_str(&serde_json::to_string(&d).unwrap()).unwrap();
257        assert_eq!(d, back);
258    }
259}