use serde::{Deserialize, Serialize};
use crate::incident::IncidentReasonCode;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArtifactPatternRewrite {
pub original_pattern: String,
pub effective_pattern: String,
pub original_target_dir: String,
pub effective_target_dir: String,
pub rewritten: bool,
}
#[must_use]
pub fn rewrite_artifact_pattern(
pattern: &str,
original_target_dir: &str,
effective_target_dir: &str,
) -> ArtifactPatternRewrite {
let unchanged = |rewritten: bool, effective: String| ArtifactPatternRewrite {
original_pattern: pattern.to_string(),
effective_pattern: effective,
original_target_dir: original_target_dir.to_string(),
effective_target_dir: effective_target_dir.to_string(),
rewritten,
};
if original_target_dir.is_empty() || original_target_dir == effective_target_dir {
return unchanged(false, pattern.to_string());
}
let orig = original_target_dir
.strip_suffix('/')
.unwrap_or(original_target_dir);
if let Some(rest) = pattern.strip_prefix(orig)
&& (rest.is_empty() || rest.starts_with('/'))
{
let eff = effective_target_dir
.strip_suffix('/')
.unwrap_or(effective_target_dir);
return unchanged(true, format!("{eff}{rest}"));
}
unchanged(false, pattern.to_string())
}
pub const ARTIFACT_OUTPUT_ROOTS: &[&str] = &["target", ".rch-target"];
#[must_use]
pub fn artifact_dest_is_safe(rel_dest: &str) -> bool {
if rel_dest.is_empty() || rel_dest.starts_with('/') {
return false;
}
if std::path::Path::new(rel_dest)
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return false;
}
let first = rel_dest.split('/').next().unwrap_or("");
first == "target" || first.starts_with(".rch-target")
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArtifactRetrievalDiagnostics {
pub original_pattern: String,
pub effective_pattern: String,
pub target_dir: String,
pub matched_files: Vec<String>,
}
impl ArtifactRetrievalDiagnostics {
#[must_use]
pub fn new(rewrite: &ArtifactPatternRewrite, matched_files: Vec<String>) -> Self {
Self {
original_pattern: rewrite.original_pattern.clone(),
effective_pattern: rewrite.effective_pattern.clone(),
target_dir: rewrite.effective_target_dir.clone(),
matched_files,
}
}
#[must_use]
pub fn is_miss(&self) -> bool {
self.matched_files.is_empty()
}
#[must_use]
pub fn miss_reason(&self) -> Option<IncidentReasonCode> {
self.is_miss().then_some(IncidentReasonCode::ArtifactMiss)
}
#[must_use]
pub fn render(&self) -> String {
let mut s = format!(
"artifact retrieval:\n original pattern: {}\n effective pattern: {}\n target dir: {}\n matched files: {}",
self.original_pattern,
self.effective_pattern,
self.target_dir,
self.matched_files.len(),
);
for f in &self.matched_files {
s.push_str("\n - ");
s.push_str(f);
}
if self.is_miss() {
s.push_str("\n -> ARTIFACT MISS (no files matched under the rewritten target dir)");
}
s
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn relative_target_pattern_is_rewritten() {
let r = rewrite_artifact_pattern("target/debug/libfoo.rlib", "target", ".rch-target");
assert!(r.rewritten);
assert_eq!(r.effective_pattern, ".rch-target/debug/libfoo.rlib");
}
#[test]
fn absolute_target_pattern_is_rewritten() {
let r = rewrite_artifact_pattern(
"/proj/target/debug/app",
"/proj/target",
"/proj/.rch-target-worker-css",
);
assert!(r.rewritten);
assert_eq!(
r.effective_pattern,
"/proj/.rch-target-worker-css/debug/app"
);
}
#[test]
fn trailing_slashes_are_tolerated() {
let r = rewrite_artifact_pattern("target/x", "target/", ".rch-target/");
assert_eq!(r.effective_pattern, ".rch-target/x");
let bare = rewrite_artifact_pattern("target", "target", ".rch-target");
assert_eq!(bare.effective_pattern, ".rch-target");
assert!(bare.rewritten);
}
#[test]
fn segment_boundary_prevents_false_prefix_match() {
let r = rewrite_artifact_pattern("target_archive/x", "target", ".rch-target");
assert!(!r.rewritten);
assert_eq!(r.effective_pattern, "target_archive/x");
}
#[test]
fn non_target_pattern_passes_through() {
let r = rewrite_artifact_pattern("dist/bundle.js", "target", ".rch-target");
assert!(!r.rewritten);
assert_eq!(r.effective_pattern, "dist/bundle.js");
}
#[test]
fn identical_dirs_are_a_noop() {
let r = rewrite_artifact_pattern("target/debug/x", "target", "target");
assert!(!r.rewritten);
assert_eq!(r.effective_pattern, "target/debug/x");
}
#[test]
fn empty_original_is_a_noop() {
let r = rewrite_artifact_pattern("target/x", "", ".rch-target");
assert!(!r.rewritten);
}
#[test]
fn diagnostics_record_match_and_render() {
let r = rewrite_artifact_pattern("target/debug/app", "target", ".rch-target");
let d = ArtifactRetrievalDiagnostics::new(&r, vec![".rch-target/debug/app".to_string()]);
assert!(!d.is_miss());
assert_eq!(d.miss_reason(), None);
assert_eq!(d.effective_pattern, ".rch-target/debug/app");
assert_eq!(d.target_dir, ".rch-target");
let text = d.render();
assert!(text.contains("original pattern: target/debug/app"));
assert!(text.contains("effective pattern: .rch-target/debug/app"));
assert!(text.contains(".rch-target/debug/app"));
}
#[test]
fn diagnostics_flag_artifact_miss() {
let r = rewrite_artifact_pattern("target/debug/app", "target", ".rch-target");
let d = ArtifactRetrievalDiagnostics::new(&r, vec![]);
assert!(d.is_miss());
assert_eq!(d.miss_reason(), Some(IncidentReasonCode::ArtifactMiss));
assert!(d.render().contains("ARTIFACT MISS"));
}
#[test]
fn diagnostics_serde_roundtrip() {
let r = rewrite_artifact_pattern("target/x", "target", ".rch-target");
let d = ArtifactRetrievalDiagnostics::new(&r, vec![".rch-target/x".to_string()]);
let back: ArtifactRetrievalDiagnostics =
serde_json::from_str(&serde_json::to_string(&d).unwrap()).unwrap();
assert_eq!(d, back);
}
}