Skip to main content

artifactize_core/cache/
content.rs

1//! Built-in content fingerprint: the hash of an Artifact's own input files.
2
3use std::{
4    collections::{BTreeMap, BTreeSet},
5    ffi::OsStr,
6    fs::File,
7    io::Read,
8    path::PathBuf,
9};
10
11use ignore::{
12    Match,
13    gitignore::{Gitignore, GitignoreBuilder},
14};
15use sha2::{Digest, Sha256};
16use tokio_util::sync::CancellationToken;
17
18use crate::{
19    config::RepoConfig,
20    platform::{self, FileKind},
21    process, scope,
22    scope::LogicalTarget,
23};
24
25/// Bound input traversal and retained per-file digest metadata for one fingerprint.
26const MAX_ENTRIES: usize = 10_000;
27/// Bound total hashing I/O so one undeclared large input cannot monopolize preparation.
28const MAX_BYTES: u64 = 1024 * 1024 * 1024;
29/// Bound inherited ignore-file parsing while supporting substantial repository rules.
30const GITIGNORE_BYTES: u64 = 1024 * 1024;
31/// Bound user-declared ignore fan-out and compiled matcher work during fingerprinting.
32const MAX_IGNORE_PATTERNS: usize = 64;
33/// Stream hashes in bounded heap memory while amortizing filesystem read calls.
34/// Content fingerprints and executable pins use the same I/O chunk, not a file-size cap.
35pub(crate) const HASH_BUFFER_BYTES: usize = 64 * 1024;
36const BUILTIN_IGNORES: [&str; 5] = [".git", "__pycache__/", "*.pyc", "target/", "node_modules/"];
37
38/// Sorted owner-relative file paths with their SHA-256, and one digest over all of them.
39pub(super) struct Files {
40    pub digest: crate::types::Sha256Digest,
41    pub files: BTreeMap<LogicalTarget, crate::types::Sha256Bytes>,
42}
43
44/// Declared `fingerprint.ignore` globs, parsed once from the declaration together with their
45/// matcher, which always excludes the built-ins too.
46#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
47#[serde(try_from = "Vec<String>", into = "Vec<String>")]
48pub struct IgnorePatterns {
49    patterns: Vec<String>,
50    matcher: Gitignore,
51}
52
53impl IgnorePatterns {
54    /// The globs as declared.
55    pub fn patterns(&self) -> &[String] {
56        &self.patterns
57    }
58}
59
60impl Default for IgnorePatterns {
61    /// No declared globs: only the built-ins.
62    fn default() -> Self {
63        Self::try_from(Vec::new()).expect("built-in ignore patterns")
64    }
65}
66
67impl TryFrom<Vec<String>> for IgnorePatterns {
68    type Error = String;
69
70    fn try_from(patterns: Vec<String>) -> Result<Self, String> {
71        let matcher = matcher(&patterns)?;
72        Ok(Self { patterns, matcher })
73    }
74}
75
76impl From<IgnorePatterns> for Vec<String> {
77    fn from(ignore: IgnorePatterns) -> Self {
78        ignore.patterns
79    }
80}
81
82fn matcher(patterns: &[String]) -> Result<Gitignore, String> {
83    if patterns.len() > MAX_IGNORE_PATTERNS
84        || patterns.iter().collect::<BTreeSet<_>>().len() != patterns.len()
85    {
86        return Err("fingerprint.ignore must contain at most 64 unique patterns.".into());
87    }
88    let mut builder = GitignoreBuilder::new(".");
89    // Match actual entry spellings on every volume: a differently cased rule must never
90    // widen the ignored set and silently hide a content change.
91    builder
92        .case_insensitive(false)
93        .expect("case-sensitive ignore matching");
94    for pattern in BUILTIN_IGNORES {
95        builder.add_line(None, pattern).expect("built-in pattern");
96    }
97    for pattern in patterns {
98        if pattern.trim().is_empty()
99            || pattern.starts_with(['!', '#'])
100            || pattern.bytes().any(|byte| byte.is_ascii_control())
101        {
102            return Err(
103                "fingerprint.ignore patterns must be nonblank .gitignore globs without negation or comments."
104                    .into(),
105            );
106        }
107        builder
108            .add_line(None, pattern)
109            .map_err(|error| format!("fingerprint.ignore: {error}"))?;
110    }
111    builder.build().map_err(|error| error.to_string())
112}
113
114/// Hash an Artifact's own inputs off the async runtime.
115pub(super) async fn files(
116    config: &RepoConfig,
117    id: &crate::types::ArtifactName,
118    inputs: &[crate::config::LogicalPath],
119    ignore: &IgnorePatterns,
120    cancellation: &CancellationToken,
121) -> Result<Files, String> {
122    if cancellation.is_cancelled() {
123        return Err(process::Error::Cancelled.to_string());
124    }
125    let walk = Walk::new(config, id, inputs, ignore)?;
126    let token = cancellation.clone();
127    crate::task::joined(tokio::task::spawn_blocking(move || walk.digest(&token)).await)
128        .map_err(|error| error.to_string())?
129}
130
131struct Walk {
132    root: PathBuf,
133    owner: PathBuf,
134    /// The owner folder relative to the repository root, where `.gitignore` bases start.
135    prefix: LogicalTarget,
136    inputs: Vec<LogicalTarget>,
137    file: bool,
138    /// Child Artifact folders and declaration files, skipped while walking.
139    excluded: BTreeSet<crate::config::ChildPrefix>,
140    ignore: Gitignore,
141}
142
143#[derive(Default)]
144struct State {
145    files: BTreeMap<LogicalTarget, crate::types::Sha256Bytes>,
146    entries: usize,
147    bytes: u64,
148}
149
150impl Walk {
151    fn new(
152        config: &RepoConfig,
153        id: &crate::types::ArtifactName,
154        inputs: &[crate::config::LogicalPath],
155        ignore: &IgnorePatterns,
156    ) -> Result<Self, String> {
157        let artifact = &config.artifacts[id];
158        let owner =
159            scope::scoped_path(&config.root, artifact.folder()).map_err(|e| e.to_string())?;
160        // Preserve the UTF-8 input contract before rendering portable separators.
161        artifact
162            .folder()
163            .to_str()
164            .ok_or("Artifact paths must be UTF-8.")?;
165        let prefix = platform::path_text(artifact.folder())
166            .parse::<LogicalTarget>()
167            .map_err(|error| error.to_string())?;
168        let excluded = artifact.children.keys().cloned().collect();
169        let inputs = inputs
170            .iter()
171            .map(|input| {
172                if input.as_str() == "." {
173                    Ok(LogicalTarget::Root)
174                } else {
175                    input
176                        .as_str()
177                        .parse::<LogicalTarget>()
178                        .map_err(|error| error.to_string())
179                }
180            })
181            .collect::<Result<Vec<_>, String>>()?;
182        Ok(Self {
183            root: config.root.clone(),
184            owner,
185            prefix,
186            inputs,
187            file: artifact.file_name().is_some(),
188            excluded,
189            ignore: ignore.matcher.clone(),
190        })
191    }
192
193    fn digest(&self, cancellation: &CancellationToken) -> Result<Files, String> {
194        let mut state = State::default();
195        for input in &self.inputs {
196            let label = if input.is_empty() {
197                "."
198            } else {
199                input.as_str()
200            };
201            let file =
202                scope::open_scoped(&self.owner, input).map_err(|e| format!("{label}: {e}"))?;
203            if self.file && !file.metadata().map_err(|e| e.to_string())?.is_file() {
204                return Err(format!(
205                    "{label}: File Artifact target must remain a regular file."
206                ));
207            }
208            if file.metadata().map_err(|e| e.to_string())?.is_dir() {
209                let mut gitignores = self.ancestors(input)?;
210                self.directory(&file, input, &mut gitignores, &mut state, cancellation)?;
211            } else if !input.ends_with(".artf") {
212                hash_file(file, input, &mut state)?;
213            }
214        }
215        let mut digest = Sha256::new();
216        for (path, file) in &state.files {
217            digest.update(path.as_bytes());
218            digest.update([0]);
219            digest.update(file.as_bytes());
220        }
221        Ok(Files {
222            digest: hex(&digest.finalize())
223                .parse()
224                .expect("SHA-256 input digest"),
225            files: state.files,
226        })
227    }
228
229    fn directory(
230        &self,
231        directory: &File,
232        path: &LogicalTarget,
233        gitignores: &mut Vec<(LogicalTarget, Gitignore)>,
234        state: &mut State,
235        cancellation: &CancellationToken,
236    ) -> Result<(), String> {
237        let label = if path.is_empty() { "." } else { path.as_str() };
238        // Enumerate the pinned directory, not a path that could have been replaced by a link.
239        let mut entries = Vec::new();
240        for entry in platform::read_dir(directory).map_err(|e| format!("{label}: {e}"))? {
241            let entry = entry.map_err(|e| format!("{label}: {e}"))?;
242            let kind = entry.file_type().map_err(|e| format!("{label}: {e}"))?;
243            let name = entry
244                .file_name()
245                .into_string()
246                .map_err(|_| "Artifact paths must be UTF-8.")?;
247            entries.push((name, kind == FileKind::Directory));
248        }
249        entries.sort();
250        let gitignore =
251            read_gitignore(directory).map_err(|e| format!("{}: {e}", gitignore_name(path)))?;
252        let pushed = gitignore.is_some();
253        if let Some(gitignore) = gitignore {
254            gitignores.push((join(&self.prefix, path)?, gitignore));
255        }
256        for (name, is_dir) in entries {
257            if cancellation.is_cancelled() {
258                return Err(process::Error::Cancelled.to_string());
259            }
260            let child = join(path, &name)?;
261            if (!is_dir && name.ends_with(".artf"))
262                || self.excluded.contains(child.as_str())
263                || self.ignored(&child, is_dir, gitignores)?
264            {
265                continue;
266            }
267            state.entries += 1;
268            if state.entries > MAX_ENTRIES {
269                return Err(format!(
270                    "Content inputs exceed {MAX_ENTRIES} entries; narrow fingerprint.files or add fingerprint.ignore."
271                ));
272            }
273            let entry = scope::open_child(directory, OsStr::new(&name))
274                .map_err(|e| format!("{child}: {e} Add it to fingerprint.ignore to skip it."))?;
275            if entry.metadata().map_err(|e| e.to_string())?.is_dir() {
276                self.directory(&entry, &child, gitignores, state, cancellation)?;
277            } else {
278                hash_file(entry, &child, state)?;
279            }
280        }
281        if pushed {
282            gitignores.pop();
283        }
284        Ok(())
285    }
286
287    /// `.gitignore` files from the repository root down to a walked input's parent folder.
288    fn ancestors(&self, input: &LogicalTarget) -> Result<Vec<(LogicalTarget, Gitignore)>, String> {
289        let full = join(&self.prefix, input)?;
290        let parts: Vec<_> = full.split('/').filter(|part| !part.is_empty()).collect();
291        let mut gitignores = Vec::new();
292        for depth in 0..parts.len() {
293            let base: LogicalTarget = parts[..depth]
294                .join("/")
295                .parse()
296                .expect("ancestors of a validated logical path");
297            let label = |e: String| format!("{}: {e}", gitignore_name(&base));
298            let directory =
299                scope::open_scoped(&self.root, &base).map_err(|e| label(e.to_string()))?;
300            if let Some(gitignore) = read_gitignore(&directory).map_err(label)? {
301                gitignores.push((base, gitignore));
302            }
303        }
304        Ok(gitignores)
305    }
306
307    /// Built-ins and declared globs always exclude; `.gitignore` files decide the rest,
308    /// each relative to its own folder and the deepest match winning, as in git.
309    fn ignored(
310        &self,
311        path: &LogicalTarget,
312        is_dir: bool,
313        gitignores: &[(LogicalTarget, Gitignore)],
314    ) -> Result<bool, String> {
315        if self.ignore.matched(path, is_dir).is_ignore() {
316            return Ok(true);
317        }
318        let full = join(&self.prefix, path)?;
319        for (base, gitignore) in gitignores.iter().rev() {
320            let relative = if base.is_empty() {
321                full.as_str()
322            } else {
323                &full[base.len() + 1..]
324            };
325            match gitignore.matched(relative, is_dir) {
326                Match::Ignore(_) => return Ok(true),
327                Match::Whitelist(_) => return Ok(false),
328                Match::None => {}
329            }
330        }
331        Ok(false)
332    }
333}
334
335/// A pinned folder's `.gitignore`, if any. Lines git would reject are skipped, as git skips them.
336fn read_gitignore(directory: &File) -> Result<Option<Gitignore>, String> {
337    match platform::entry_kind(directory, OsStr::new(".gitignore")) {
338        Ok(FileKind::Directory) => return Ok(None),
339        Ok(_) => {}
340        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
341        Err(error) => return Err(error.to_string()),
342    }
343    let file = scope::open_child(directory, OsStr::new(".gitignore")).map_err(|e| e.to_string())?;
344    let mut text = Vec::new();
345    file.take(GITIGNORE_BYTES + 1)
346        .read_to_end(&mut text)
347        .map_err(|e| e.to_string())?;
348    if text.len() as u64 > GITIGNORE_BYTES {
349        return Err("A .gitignore file exceeds 1 MiB.".into());
350    }
351    let mut builder = GitignoreBuilder::new(".");
352    // Match actual entry spellings on every volume: a differently cased rule must never
353    // widen the ignored set and silently hide a content change.
354    builder
355        .case_insensitive(false)
356        .expect("case-sensitive ignore matching");
357    for line in String::from_utf8_lossy(&text).lines() {
358        let _ = builder.add_line(None, line);
359    }
360    builder.build().map(Some).map_err(|e| e.to_string())
361}
362
363fn hash_file(mut file: File, path: &LogicalTarget, state: &mut State) -> Result<(), String> {
364    if !file.metadata().map_err(|e| e.to_string())?.is_file() {
365        return Err(format!("{path}: Content input must be a regular file."));
366    }
367    let mut digest = Sha256::new();
368    let mut buffer = vec![0; HASH_BUFFER_BYTES];
369    loop {
370        let read = file.read(&mut buffer).map_err(|e| format!("{path}: {e}"))?;
371        if read == 0 {
372            break;
373        }
374        state.bytes += read as u64;
375        if state.bytes > MAX_BYTES {
376            return Err(
377                "Content inputs exceed 1 GiB; narrow fingerprint.files or add fingerprint.ignore."
378                    .into(),
379            );
380        }
381        digest.update(&buffer[..read]);
382    }
383    state.files.insert(
384        path.clone(),
385        crate::types::Sha256Bytes::from_digest(digest.finalize().into()),
386    );
387    Ok(())
388}
389
390/// The `.gitignore` of a folder, for messages: its logical path, or when that would be too
391/// long to be one, the folder and the name as written.
392fn gitignore_name(folder: &LogicalTarget) -> String {
393    join(folder, ".gitignore")
394        .map_or_else(|_| format!("{folder}/.gitignore"), |path| path.to_string())
395}
396
397fn join(base: &LogicalTarget, name: &str) -> Result<LogicalTarget, String> {
398    if name.is_empty() {
399        Ok(base.clone())
400    } else {
401        let path = if base.is_empty() {
402            name.to_owned()
403        } else {
404            format!("{base}/{name}")
405        };
406        path.parse::<LogicalTarget>()
407            .map_err(|error| error.to_string())
408    }
409}
410
411pub(super) fn hex(bytes: &[u8]) -> String {
412    bytes.iter().map(|byte| format!("{byte:02x}")).collect()
413}