//! Artifact collection: expand a job's globs inside its workspace and flatten the matches into //! upload names. //! //! The walk never follows symlinks, only regular files count, and every match is canonicalized //! and checked to stay inside the canonical workspace. use std::collections::HashSet; use std::fs::FileType; use std::path::{Path, PathBuf}; use globset::{GlobBuilder, GlobSet, GlobSetBuilder}; /// The server accepts names matching `[A-Za-z0-9._+-]{1,128}` and not starting with `.`. pub const MAX_NAME_LEN: usize = 128; /// The server caps artifacts per job at 50 by default. pub const MAX_FILES: usize = 50; /// Errors from collecting artifacts. Any of them fails the job. #[derive(Debug, thiserror::Error)] pub enum ArtifactError { #[error("invalid artifact pattern {0:?}: must be relative, without ..")] Pattern(String), #[error("invalid artifact glob: {0}")] Glob(#[from] globset::Error), #[error("cannot flatten artifact path {0:?} into a valid name")] Name(String), #[error("two artifacts flatten to the same name {0:?}")] Duplicate(String), #[error("artifact {name:?} is {size} bytes, over the {limit} byte limit")] TooLarge { name: String, size: u64, limit: u64 }, #[error("more than {MAX_FILES} artifacts matched")] TooMany, #[error("artifact io: {0}")] Io(#[from] std::io::Error), } /// One file to upload. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Artifact { /// The flattened upload name. pub name: String, /// The file on disk, inside the workspace. pub path: PathBuf, pub size: u64, } /// Turns a workspace-relative path into one upload name: `/` becomes `_`, characters outside /// the server alphabet become `_`, and a leading `.` is replaced so the name is never hidden. pub fn flatten_name(rel: &str) -> Result { let joined = rel .split('/') .filter(|c| !c.is_empty()) .collect::>() .join("_"); let mut name: String = joined .chars() .map(|c| { if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '+' | '-') { c } else { '_' } }) .collect(); while name.starts_with('.') { name.replace_range(0..1, "_"); } if name.is_empty() || name.len() > MAX_NAME_LEN { return Err(ArtifactError::Name(rel.to_owned())); } Ok(name) } /// Rejects patterns that are absolute or climb out of the workspace. pub fn check_pattern(pattern: &str) -> Result<(), ArtifactError> { let bad = pattern.is_empty() || pattern.starts_with('/') || pattern.split('/').any(|c| c == ".."); if bad { Err(ArtifactError::Pattern(pattern.to_owned())) } else { Ok(()) } } fn build_globs(patterns: &[String]) -> Result { let mut builder = GlobSetBuilder::new(); for p in patterns { check_pattern(p)?; builder.add(GlobBuilder::new(p).literal_separator(true).build()?); } Ok(builder.build()?) } /// Expands `patterns` inside `workspace`. Returns the matches sorted by name. pub fn collect( workspace: &Path, patterns: &[String], max_bytes_per_file: u64, ) -> Result, ArtifactError> { if patterns.is_empty() { return Ok(Vec::new()); } let globs = build_globs(patterns)?; let root = workspace.canonicalize()?; let mut found: Vec<(String, PathBuf)> = Vec::new(); let mut stack: Vec<(PathBuf, String)> = vec![(root.clone(), String::new())]; while let Some((dir, prefix)) = stack.pop() { let mut entries: Vec<_> = std::fs::read_dir(&dir)?.collect::>()?; entries.sort_by_key(|e| e.file_name()); for entry in entries { let file_name = entry.file_name(); let Some(file_name) = file_name.to_str() else { continue; }; let rel = if prefix.is_empty() { file_name.to_owned() } else { format!("{prefix}/{file_name}") }; // DirEntry::file_type does not follow symlinks. let file_type: FileType = entry.file_type()?; if file_type.is_symlink() { continue; } if file_type.is_dir() { if file_name != ".git" { stack.push((entry.path(), rel)); } } else if file_type.is_file() && globs.is_match(&rel) { found.push((rel, entry.path())); } } } found.sort(); if found.len() > MAX_FILES { return Err(ArtifactError::TooMany); } let mut seen = HashSet::new(); let mut out = Vec::with_capacity(found.len()); for (rel, path) in found { // Defense in depth: the walk never enters symlinks, but check the real location too. let real = path.canonicalize()?; if !real.starts_with(&root) { continue; } let meta = std::fs::symlink_metadata(&real)?; if !meta.is_file() { continue; } let name = flatten_name(&rel)?; if !seen.insert(name.clone()) { return Err(ArtifactError::Duplicate(name)); } if meta.len() > max_bytes_per_file { return Err(ArtifactError::TooLarge { name, size: meta.len(), limit: max_bytes_per_file, }); } out.push(Artifact { name, path: real, size: meta.len(), }); } Ok(out) } #[cfg(test)] mod tests { use super::*; use std::os::unix::fs::symlink; fn ws_with(files: &[(&str, &str)]) -> (tempfile::TempDir, PathBuf) { let dir = tempfile::tempdir().expect("tempdir"); let ws = dir.path().join("ws"); std::fs::create_dir_all(&ws).expect("mkdir"); for (rel, body) in files { let p = ws.join(rel); std::fs::create_dir_all(p.parent().expect("parent")).expect("mkdir"); std::fs::write(&p, body).expect("write"); } (dir, ws) } fn names(found: &[Artifact]) -> Vec<&str> { found.iter().map(|a| a.name.as_str()).collect() } #[test] fn flattening_maps_separators_and_hidden_names() { assert_eq!( flatten_name("dist/app/v1.tar.gz").expect("ok"), "dist_app_v1.tar.gz" ); assert_eq!(flatten_name(".hidden/x").expect("ok"), "_hidden_x"); assert_eq!(flatten_name("a b/c~d").expect("ok"), "a_b_c_d"); assert!(flatten_name("").is_err()); assert!(flatten_name(&"x".repeat(MAX_NAME_LEN + 1)).is_err()); } #[test] fn globs_match_inside_the_workspace() { let (_dir, ws) = ws_with(&[ ("dist/app.bin", "a"), ("dist/sub/b.txt", "b"), ("other.txt", "c"), ]); let found = collect(&ws, &["dist/**".to_owned()], 1024).expect("collect"); assert_eq!(names(&found), vec!["dist_app.bin", "dist_sub_b.txt"]); let found = collect(&ws, &["dist/*".to_owned()], 1024).expect("collect"); assert_eq!(names(&found), vec!["dist_app.bin"]); } #[test] fn parent_directory_patterns_are_rejected() { let (_dir, ws) = ws_with(&[("x.txt", "x")]); assert!(matches!( collect(&ws, &["../*".to_owned()], 1024), Err(ArtifactError::Pattern(_)) )); assert!(matches!( collect(&ws, &["/etc/passwd".to_owned()], 1024), Err(ArtifactError::Pattern(_)) )); } #[test] fn a_file_outside_the_workspace_is_never_reached() { let (dir, ws) = ws_with(&[("x.txt", "x")]); std::fs::write(dir.path().join("secret.txt"), "outside").expect("write"); symlink(dir.path().join("secret.txt"), ws.join("link.txt")).expect("symlink"); let found = collect(&ws, &["**".to_owned()], 1024).expect("collect"); assert_eq!(names(&found), vec!["x.txt"]); } #[test] fn symlink_to_etc_is_skipped() { let (_dir, ws) = ws_with(&[("dist/real.txt", "r")]); symlink("/etc/passwd", ws.join("dist/passwd")).expect("symlink"); symlink("/etc", ws.join("etc-dir")).expect("symlink"); let found = collect(&ws, &["**".to_owned()], 1024).expect("collect"); assert_eq!(names(&found), vec!["dist_real.txt"]); } #[test] fn oversized_files_are_refused() { let (_dir, ws) = ws_with(&[("big.bin", "0123456789")]); assert!(matches!( collect(&ws, &["*.bin".to_owned()], 4), Err(ArtifactError::TooLarge { .. }) )); } #[test] fn colliding_flattened_names_are_refused() { let (_dir, ws) = ws_with(&[("a/b.txt", "1"), ("a_b.txt", "2")]); assert!(matches!( collect(&ws, &["**".to_owned()], 1024), Err(ArtifactError::Duplicate(_)) )); } #[test] fn git_dir_is_not_collected() { let (_dir, ws) = ws_with(&[(".git/config", "c"), ("keep.txt", "k")]); let found = collect(&ws, &["**".to_owned()], 1024).expect("collect"); assert_eq!(names(&found), vec!["keep.txt"]); } }