1use 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
25const MAX_ENTRIES: usize = 10_000;
27const MAX_BYTES: u64 = 1024 * 1024 * 1024;
29const GITIGNORE_BYTES: u64 = 1024 * 1024;
31const MAX_IGNORE_PATTERNS: usize = 64;
33pub(crate) const HASH_BUFFER_BYTES: usize = 64 * 1024;
36const BUILTIN_IGNORES: [&str; 5] = [".git", "__pycache__/", "*.pyc", "target/", "node_modules/"];
37
38pub(super) struct Files {
40 pub digest: crate::types::Sha256Digest,
41 pub files: BTreeMap<LogicalTarget, crate::types::Sha256Bytes>,
42}
43
44#[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 pub fn patterns(&self) -> &[String] {
56 &self.patterns
57 }
58}
59
60impl Default for IgnorePatterns {
61 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 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
114pub(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 prefix: LogicalTarget,
136 inputs: Vec<LogicalTarget>,
137 file: bool,
138 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 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 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 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 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
335fn 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 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
390fn 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}