use std::{
collections::{BTreeMap, BTreeSet},
ffi::OsStr,
fs::File,
io::Read,
path::PathBuf,
};
use ignore::{
Match,
gitignore::{Gitignore, GitignoreBuilder},
};
use sha2::{Digest, Sha256};
use tokio_util::sync::CancellationToken;
use crate::{
config::RepoConfig,
platform::{self, FileKind},
process, scope,
scope::LogicalTarget,
};
const MAX_ENTRIES: usize = 10_000;
const MAX_BYTES: u64 = 1024 * 1024 * 1024;
const GITIGNORE_BYTES: u64 = 1024 * 1024;
const MAX_IGNORE_PATTERNS: usize = 64;
pub(crate) const HASH_BUFFER_BYTES: usize = 64 * 1024;
const BUILTIN_IGNORES: [&str; 5] = [".git", "__pycache__/", "*.pyc", "target/", "node_modules/"];
pub(super) struct Files {
pub digest: crate::types::Sha256Digest,
pub files: BTreeMap<LogicalTarget, crate::types::Sha256Bytes>,
}
pub(crate) fn ignore_patterns(patterns: &[String]) -> Result<(), String> {
matcher(patterns).map(drop)
}
fn matcher(patterns: &[String]) -> Result<Gitignore, String> {
if patterns.len() > MAX_IGNORE_PATTERNS
|| patterns.iter().collect::<BTreeSet<_>>().len() != patterns.len()
{
return Err("fingerprint.ignore must contain at most 64 unique patterns.".into());
}
let mut builder = GitignoreBuilder::new(".");
builder
.case_insensitive(false)
.expect("case-sensitive ignore matching");
for pattern in BUILTIN_IGNORES {
builder.add_line(None, pattern).expect("built-in pattern");
}
for pattern in patterns {
if pattern.trim().is_empty()
|| pattern.starts_with(['!', '#'])
|| pattern.bytes().any(|byte| byte.is_ascii_control())
{
return Err(
"fingerprint.ignore patterns must be nonblank .gitignore globs without negation or comments."
.into(),
);
}
builder
.add_line(None, pattern)
.map_err(|error| format!("fingerprint.ignore: {error}"))?;
}
builder.build().map_err(|error| error.to_string())
}
pub(super) async fn files(
config: &RepoConfig,
id: &crate::types::ArtifactName,
inputs: &[crate::config::LogicalPath],
ignore: &[String],
cancellation: &CancellationToken,
) -> Result<Files, String> {
if cancellation.is_cancelled() {
return Err(process::Error::Cancelled.to_string());
}
let walk = Walk::new(config, id, inputs, ignore)?;
let token = cancellation.clone();
crate::task::joined(tokio::task::spawn_blocking(move || walk.digest(&token)).await)
.map_err(|error| error.to_string())?
}
struct Walk {
root: PathBuf,
owner: PathBuf,
prefix: LogicalTarget,
inputs: Vec<LogicalTarget>,
file: bool,
excluded: BTreeSet<crate::config::ChildPrefix>,
ignore: Gitignore,
}
#[derive(Default)]
struct State {
files: BTreeMap<LogicalTarget, crate::types::Sha256Bytes>,
entries: usize,
bytes: u64,
}
impl Walk {
fn new(
config: &RepoConfig,
id: &crate::types::ArtifactName,
inputs: &[crate::config::LogicalPath],
ignore: &[String],
) -> Result<Self, String> {
let artifact = &config.artifacts[id];
let owner =
scope::scoped_path(&config.root, artifact.folder()).map_err(|e| e.to_string())?;
artifact
.folder()
.to_str()
.ok_or("Artifact paths must be UTF-8.")?;
let prefix = platform::path_text(artifact.folder())
.parse::<LogicalTarget>()
.map_err(|error| error.to_string())?;
let excluded = artifact.children.keys().cloned().collect();
let inputs = inputs
.iter()
.map(|input| {
if input.as_str() == "." {
Ok(LogicalTarget::Root)
} else {
input
.as_str()
.parse::<LogicalTarget>()
.map_err(|error| error.to_string())
}
})
.collect::<Result<Vec<_>, String>>()?;
Ok(Self {
root: config.root.clone(),
owner,
prefix,
inputs,
file: artifact.file_name().is_some(),
excluded,
ignore: matcher(ignore)?,
})
}
fn digest(&self, cancellation: &CancellationToken) -> Result<Files, String> {
let mut state = State::default();
for input in &self.inputs {
let label = if input.is_empty() {
"."
} else {
input.as_str()
};
let file =
scope::open_scoped(&self.owner, input).map_err(|e| format!("{label}: {e}"))?;
if self.file && !file.metadata().map_err(|e| e.to_string())?.is_file() {
return Err(format!(
"{label}: File Artifact target must remain a regular file."
));
}
if file.metadata().map_err(|e| e.to_string())?.is_dir() {
let mut gitignores = self.ancestors(input)?;
self.directory(&file, input, &mut gitignores, &mut state, cancellation)?;
} else if !input.ends_with(".artf") {
hash_file(file, input, &mut state)?;
}
}
let mut digest = Sha256::new();
for (path, file) in &state.files {
digest.update(path.as_bytes());
digest.update([0]);
digest.update(file.as_bytes());
}
Ok(Files {
digest: hex(&digest.finalize())
.parse()
.expect("SHA-256 input digest"),
files: state.files,
})
}
fn directory(
&self,
directory: &File,
path: &LogicalTarget,
gitignores: &mut Vec<(LogicalTarget, Gitignore)>,
state: &mut State,
cancellation: &CancellationToken,
) -> Result<(), String> {
let label = if path.is_empty() { "." } else { path.as_str() };
let mut entries = Vec::new();
for entry in platform::read_dir(directory).map_err(|e| format!("{label}: {e}"))? {
let entry = entry.map_err(|e| format!("{label}: {e}"))?;
let kind = entry.file_type().map_err(|e| format!("{label}: {e}"))?;
let name = entry
.file_name()
.into_string()
.map_err(|_| "Artifact paths must be UTF-8.")?;
entries.push((name, kind == FileKind::Directory));
}
entries.sort();
let gitignore =
read_gitignore(directory).map_err(|e| format!("{}: {e}", gitignore_name(path)))?;
let pushed = gitignore.is_some();
if let Some(gitignore) = gitignore {
gitignores.push((join(&self.prefix, path)?, gitignore));
}
for (name, is_dir) in entries {
if cancellation.is_cancelled() {
return Err(process::Error::Cancelled.to_string());
}
let child = join(path, &name)?;
if (!is_dir && name.ends_with(".artf"))
|| self.excluded.contains(child.as_str())
|| self.ignored(&child, is_dir, gitignores)?
{
continue;
}
state.entries += 1;
if state.entries > MAX_ENTRIES {
return Err(format!(
"Content inputs exceed {MAX_ENTRIES} entries; narrow fingerprint.files or add fingerprint.ignore."
));
}
let entry = scope::open_child(directory, OsStr::new(&name))
.map_err(|e| format!("{child}: {e} Add it to fingerprint.ignore to skip it."))?;
if entry.metadata().map_err(|e| e.to_string())?.is_dir() {
self.directory(&entry, &child, gitignores, state, cancellation)?;
} else {
hash_file(entry, &child, state)?;
}
}
if pushed {
gitignores.pop();
}
Ok(())
}
fn ancestors(&self, input: &LogicalTarget) -> Result<Vec<(LogicalTarget, Gitignore)>, String> {
let full = join(&self.prefix, input)?;
let parts: Vec<_> = full.split('/').filter(|part| !part.is_empty()).collect();
let mut gitignores = Vec::new();
for depth in 0..parts.len() {
let base: LogicalTarget = parts[..depth]
.join("/")
.parse()
.expect("ancestors of a validated logical path");
let label = |e: String| format!("{}: {e}", gitignore_name(&base));
let directory =
scope::open_scoped(&self.root, &base).map_err(|e| label(e.to_string()))?;
if let Some(gitignore) = read_gitignore(&directory).map_err(label)? {
gitignores.push((base, gitignore));
}
}
Ok(gitignores)
}
fn ignored(
&self,
path: &LogicalTarget,
is_dir: bool,
gitignores: &[(LogicalTarget, Gitignore)],
) -> Result<bool, String> {
if self.ignore.matched(path, is_dir).is_ignore() {
return Ok(true);
}
let full = join(&self.prefix, path)?;
for (base, gitignore) in gitignores.iter().rev() {
let relative = if base.is_empty() {
full.as_str()
} else {
&full[base.len() + 1..]
};
match gitignore.matched(relative, is_dir) {
Match::Ignore(_) => return Ok(true),
Match::Whitelist(_) => return Ok(false),
Match::None => {}
}
}
Ok(false)
}
}
fn read_gitignore(directory: &File) -> Result<Option<Gitignore>, String> {
match platform::entry_kind(directory, OsStr::new(".gitignore")) {
Ok(FileKind::Directory) => return Ok(None),
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error.to_string()),
}
let file = scope::open_child(directory, OsStr::new(".gitignore")).map_err(|e| e.to_string())?;
let mut text = Vec::new();
file.take(GITIGNORE_BYTES + 1)
.read_to_end(&mut text)
.map_err(|e| e.to_string())?;
if text.len() as u64 > GITIGNORE_BYTES {
return Err("A .gitignore file exceeds 1 MiB.".into());
}
let mut builder = GitignoreBuilder::new(".");
builder
.case_insensitive(false)
.expect("case-sensitive ignore matching");
for line in String::from_utf8_lossy(&text).lines() {
let _ = builder.add_line(None, line);
}
builder.build().map(Some).map_err(|e| e.to_string())
}
fn hash_file(mut file: File, path: &LogicalTarget, state: &mut State) -> Result<(), String> {
if !file.metadata().map_err(|e| e.to_string())?.is_file() {
return Err(format!("{path}: Content input must be a regular file."));
}
let mut digest = Sha256::new();
let mut buffer = vec![0; HASH_BUFFER_BYTES];
loop {
let read = file.read(&mut buffer).map_err(|e| format!("{path}: {e}"))?;
if read == 0 {
break;
}
state.bytes += read as u64;
if state.bytes > MAX_BYTES {
return Err(
"Content inputs exceed 1 GiB; narrow fingerprint.files or add fingerprint.ignore."
.into(),
);
}
digest.update(&buffer[..read]);
}
state.files.insert(
path.clone(),
crate::types::Sha256Bytes::from_digest(digest.finalize().into()),
);
Ok(())
}
fn gitignore_name(folder: &LogicalTarget) -> String {
join(folder, ".gitignore")
.map_or_else(|_| format!("{folder}/.gitignore"), |path| path.to_string())
}
fn join(base: &LogicalTarget, name: &str) -> Result<LogicalTarget, String> {
if name.is_empty() {
Ok(base.clone())
} else {
let path = if base.is_empty() {
name.to_owned()
} else {
format!("{base}/{name}")
};
path.parse::<LogicalTarget>()
.map_err(|error| error.to_string())
}
}
pub(super) fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}