use std::collections::BTreeMap; use std::io::Read; use std::path::{Path, PathBuf}; use glob::{MatchOptions, Pattern}; use sha2::{Digest, Sha256}; use crate::{Error, Result}; pub(crate) const MATCH_OPTIONS: MatchOptions = MatchOptions { case_sensitive: true, require_literal_separator: true, require_literal_leading_dot: false, }; #[derive(Debug, thiserror::Error)] pub enum PatternError { #[error("Glob patterns cannot be empty")] Empty, #[error("Glob patterns use '/' as the separator")] Separator, #[error(transparent)] Syntax(#[from] glob::PatternError), #[error("Task outputs must be package-relative glob patterns that stay inside the package")] OutputOutsidePackage, } pub fn validate_pattern(pattern: &str) -> std::result::Result<(), PatternError> { if pattern.is_empty() { return Err(PatternError::Empty); } if pattern.contains('\\') { return Err(PatternError::Separator); } Pattern::new(&normalize_pattern(pattern))?; Ok(()) } pub fn validate_output_pattern(pattern: &str) -> std::result::Result<(), PatternError> { let normalized = pattern.strip_prefix("./").unwrap_or(pattern); if normalized.is_empty() || normalized == "." || normalized.starts_with('/') || normalized.split('/').any(|part| part == "..") { return Err(PatternError::OutputOutsidePackage); } Ok(()) } /// Strips a leading `./` and trailing `/`s, which name the package root or /// a directory and carry no meaning for matching. pub fn normalize_pattern(pattern: &str) -> String { let mut normalized = pattern; while let Some(rest) = normalized.strip_prefix("./") { normalized = rest; } normalized.trim_end_matches('/').to_owned() } /// Whether a pattern matches `relative` or a directory above it: a /// pattern naming a directory covers everything underneath it. pub fn covers(pattern: &Pattern, relative: &str) -> bool { if pattern.matches_with(relative, MATCH_OPTIONS) { return true; } relative .match_indices('/') .any(|(index, _)| pattern.matches_with(&relative[..index], MATCH_OPTIONS)) } pub fn compile_pattern(task: &str, pattern: &str) -> Result { Pattern::new(&normalize_pattern(pattern)).map_err(|error| Error::Pattern { task: task.to_owned(), pattern: pattern.to_owned(), message: error.to_string(), }) } /// Expands `files` patterns against the tree under `root`. Every pattern /// must match at least one file. pub fn expand_files( root: &Path, task: &str, patterns: &[String], ) -> Result> { let files = collect_paths(root, false); let mut matched = BTreeMap::new(); for pattern in patterns { let compiled = compile_pattern(task, pattern)?; let mut any = false; for relative in &files { if covers(&compiled, relative) { any = true; matched.insert(relative.clone(), root.join(relative)); } } if !any { return Err(Error::UnmatchedFiles { task: task.to_owned(), pattern: pattern.to_owned(), }); } } Ok(matched) } /// Slash-separated paths of everything under `base`, relative to it. pub fn collect_paths(base: &Path, include_directories: bool) -> Vec { fn walk(directory: &Path, prefix: &str, include_directories: bool, paths: &mut Vec) { let Ok(entries) = std::fs::read_dir(directory) else { return; }; let mut entries: Vec<_> = entries.filter_map(std::result::Result::ok).collect(); entries.sort_by_key(|entry| entry.file_name()); for entry in entries { let name = entry.file_name().to_string_lossy().into_owned(); let relative = if prefix.is_empty() { name } else { format!("{prefix}/{name}") }; let is_directory = entry.file_type().is_ok_and(|kind| kind.is_dir()); if is_directory { if include_directories { paths.push(relative.clone()); } walk(&entry.path(), &relative, include_directories, paths); } else { paths.push(relative); } } } let mut paths = Vec::new(); walk(base, "", include_directories, &mut paths); paths } /// A file's cache identity: size and modification time, without reading it. pub(crate) fn file_id(path: &Path) -> std::io::Result { let metadata = std::fs::metadata(path)?; let modified = match metadata.modified() { Ok(mtime) => mtime.duration_since(std::time::UNIX_EPOCH).ok(), Err(_) => return hash_id(path), }; let Some(modified) = modified else { return hash_id(path); }; let mut id = format!( "file:{}:{}:{}", metadata.len(), modified.as_secs(), modified.subsec_nanos() ); if path .symlink_metadata() .is_ok_and(|link| link.file_type().is_symlink()) && let Ok(target) = std::fs::read_link(path) { id.push_str(&format!("->{}", target.to_string_lossy())); } Ok(id) } fn hash_id(path: &Path) -> std::io::Result { let mut file = std::fs::File::open(path)?; let mut hasher = Sha256::new(); let mut buffer = [0; 8192]; loop { let read = file.read(&mut buffer)?; if read == 0 { break; } hasher.update(&buffer[..read]); } Ok(format!("file:hash:{:x}", hasher.finalize())) }