rch_common/
artifact_pattern.rs1use serde::{Deserialize, Serialize};
14
15use crate::incident::IncidentReasonCode;
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct ArtifactPatternRewrite {
20 pub original_pattern: String,
22 pub effective_pattern: String,
24 pub original_target_dir: String,
26 pub effective_target_dir: String,
28 pub rewritten: bool,
31}
32
33#[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 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
75pub const ARTIFACT_OUTPUT_ROOTS: &[&str] = &["target", ".rch-target"];
78
79#[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 first == "target" || first.starts_with(".rch-target")
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct ArtifactRetrievalDiagnostics {
107 pub original_pattern: String,
109 pub effective_pattern: String,
111 pub target_dir: String,
113 pub matched_files: Vec<String>,
115}
116
117impl ArtifactRetrievalDiagnostics {
118 #[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 #[must_use]
131 pub fn is_miss(&self) -> bool {
132 self.matched_files.is_empty()
133 }
134
135 #[must_use]
137 pub fn miss_reason(&self) -> Option<IncidentReasonCode> {
138 self.is_miss().then_some(IncidentReasonCode::ArtifactMiss)
139 }
140
141 #[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 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 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 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}