clusterflux-public/crates/clusterflux-node/src/source_snapshot.rs
Clusterflux release 70319cde15 Public release release-ff7f847f143d
Source commit: ff7f847f143dc67864bed68a86cf259114384bd5

Public tree identity: sha256:bb7dcd2ac78bdbad2f4eba0f49be649d446d67f96f4eb2796941c026412fc032
2026-07-17 09:33:47 +02:00

424 lines
14 KiB
Rust

use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::Command;
use clusterflux_core::Digest;
use sha2::{Digest as _, Sha256};
const MAX_SNAPSHOT_FILES: usize = 20_000;
const MAX_SNAPSHOT_FILE_BYTES: u64 = 256 * 1024 * 1024;
const MAX_SNAPSHOT_TOTAL_BYTES: u64 = 1024 * 1024 * 1024;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct SourceSnapshotInventory {
pub(crate) digest: Digest,
pub(crate) provider: &'static str,
pub(crate) file_count: usize,
pub(crate) total_bytes: u64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct SnapshotFile {
path: String,
kind: &'static str,
executable: bool,
digest: Digest,
size_bytes: u64,
}
pub(crate) fn snapshot_project(project_root: &Path) -> Result<SourceSnapshotInventory, String> {
let project_root = project_root
.canonicalize()
.map_err(|error| format!("resolve source checkout: {error}"))?;
if !project_root.is_dir() {
return Err("source checkout root is not a directory".to_owned());
}
if let Some(repository_root) = git_repository_root(&project_root)? {
snapshot_git_project(&repository_root, &project_root)
} else {
snapshot_filesystem_project(&project_root)
}
}
fn snapshot_git_project(
repository_root: &Path,
project_root: &Path,
) -> Result<SourceSnapshotInventory, String> {
let project_prefix = project_root
.strip_prefix(repository_root)
.map_err(|_| "source checkout is outside its discovered Git repository".to_owned())?;
let project_prefix = if project_prefix.as_os_str().is_empty() {
".".to_owned()
} else {
slash_path(project_prefix)?
};
let mut command = git_command(repository_root);
command
.args([
"ls-files",
"-z",
"--cached",
"--others",
"--exclude-standard",
"--",
])
.arg(&project_prefix);
let output = command
.output()
.map_err(|error| format!("enumerate Git source snapshot: {error}"))?;
if !output.status.success() {
return Err(format!(
"enumerate Git source snapshot failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
));
}
let mut files = Vec::new();
for raw in output
.stdout
.split(|byte| *byte == 0)
.filter(|raw| !raw.is_empty())
{
let repository_relative =
std::str::from_utf8(raw).map_err(|_| "Git source path is not UTF-8".to_owned())?;
let absolute = repository_root.join(repository_relative);
let relative = absolute
.strip_prefix(project_root)
.map_err(|_| "Git enumerated source outside the selected project".to_owned())?;
files.push(snapshot_file(&absolute, &slash_path(relative)?)?);
}
files.sort_by(|left, right| left.path.cmp(&right.path));
files.dedup_by(|left, right| left.path == right.path);
validate_snapshot_bounds(&files)?;
let head =
git_text(repository_root, &["rev-parse", "HEAD"]).unwrap_or_else(|| "unborn".to_owned());
let roots = git_text(repository_root, &["rev-list", "--max-parents=0", "HEAD"])
.unwrap_or_else(|| "unborn".to_owned());
let remote = git_text(repository_root, &["config", "--get", "remote.origin.url"])
.unwrap_or_else(|| "no-origin".to_owned());
let submodules = git_text(
repository_root,
&["submodule", "status", "--recursive", "--", &project_prefix],
)
.unwrap_or_else(|| "no-submodules".to_owned());
finish_snapshot(
"git",
[
b"node-source-snapshot:git:v1".to_vec(),
head.into_bytes(),
roots.into_bytes(),
remote.into_bytes(),
submodules.into_bytes(),
project_prefix.into_bytes(),
],
files,
)
}
fn snapshot_filesystem_project(project_root: &Path) -> Result<SourceSnapshotInventory, String> {
let mut paths = Vec::new();
collect_filesystem_paths(project_root, project_root, &mut paths)?;
paths.sort();
if paths.len() > MAX_SNAPSHOT_FILES {
return Err(format!(
"source snapshot has {} files; limit is {MAX_SNAPSHOT_FILES}",
paths.len()
));
}
let mut files = Vec::with_capacity(paths.len());
for absolute in paths {
let relative = absolute
.strip_prefix(project_root)
.map_err(|_| "filesystem source escaped its project root".to_owned())?;
files.push(snapshot_file(&absolute, &slash_path(relative)?)?);
}
validate_snapshot_bounds(&files)?;
finish_snapshot(
"filesystem",
[b"node-source-snapshot:filesystem:v1".to_vec()],
files,
)
}
fn finish_snapshot<const N: usize>(
provider: &'static str,
identity_parts: [Vec<u8>; N],
files: Vec<SnapshotFile>,
) -> Result<SourceSnapshotInventory, String> {
let mut parts = identity_parts.into_iter().collect::<Vec<_>>();
let mut total_bytes = 0_u64;
for file in &files {
total_bytes = total_bytes
.checked_add(file.size_bytes)
.ok_or_else(|| "source snapshot byte accounting overflowed".to_owned())?;
parts.push(file.path.as_bytes().to_vec());
parts.push(file.kind.as_bytes().to_vec());
parts.push(if file.executable { b"x" } else { b"-" }.to_vec());
parts.push(file.digest.as_str().as_bytes().to_vec());
parts.push(file.size_bytes.to_string().into_bytes());
}
Ok(SourceSnapshotInventory {
digest: Digest::from_parts(parts),
provider,
file_count: files.len(),
total_bytes,
})
}
fn snapshot_file(absolute: &Path, relative: &str) -> Result<SnapshotFile, String> {
validate_relative_source_path(relative)?;
let metadata = match fs::symlink_metadata(absolute) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(SnapshotFile {
path: relative.to_owned(),
kind: "deleted",
executable: false,
digest: Digest::sha256("deleted"),
size_bytes: 0,
})
}
Err(error) => return Err(error.to_string()),
};
if metadata.file_type().is_symlink() {
let target = fs::read_link(absolute).map_err(|error| error.to_string())?;
let target = target
.to_str()
.ok_or_else(|| "source symlink target is not UTF-8".to_owned())?;
return Ok(SnapshotFile {
path: relative.to_owned(),
kind: "symlink",
executable: false,
digest: Digest::from_parts([b"source-symlink:v1".as_slice(), target.as_bytes()]),
size_bytes: target.len() as u64,
});
}
if !metadata.is_file() {
return Err(format!("source snapshot entry `{relative}` is not a file"));
}
if metadata.len() > MAX_SNAPSHOT_FILE_BYTES {
return Err(format!(
"source file `{relative}` is {} bytes; per-file limit is {MAX_SNAPSHOT_FILE_BYTES}",
metadata.len()
));
}
let mut file = fs::File::open(absolute).map_err(|error| error.to_string())?;
let mut hasher = Sha256::new();
let mut size_bytes = 0_u64;
let mut buffer = [0_u8; 64 * 1024];
loop {
let read = file.read(&mut buffer).map_err(|error| error.to_string())?;
if read == 0 {
break;
}
size_bytes = size_bytes
.checked_add(read as u64)
.ok_or_else(|| "source file size overflowed".to_owned())?;
if size_bytes > MAX_SNAPSHOT_FILE_BYTES {
return Err(format!(
"source file `{relative}` grew beyond {MAX_SNAPSHOT_FILE_BYTES} bytes while hashing"
));
}
hasher.update(&buffer[..read]);
}
let digest = Digest::from_sha256_hex(format!("{:x}", hasher.finalize()))?;
#[cfg(unix)]
let executable = {
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode() & 0o111 != 0
};
#[cfg(not(unix))]
let executable = false;
Ok(SnapshotFile {
path: relative.to_owned(),
kind: "file",
executable,
digest,
size_bytes,
})
}
fn validate_snapshot_bounds(files: &[SnapshotFile]) -> Result<(), String> {
if files.len() > MAX_SNAPSHOT_FILES {
return Err(format!(
"source snapshot has {} files; limit is {MAX_SNAPSHOT_FILES}",
files.len()
));
}
let total = files
.iter()
.try_fold(0_u64, |total, file| total.checked_add(file.size_bytes))
.ok_or_else(|| "source snapshot byte accounting overflowed".to_owned())?;
if total > MAX_SNAPSHOT_TOTAL_BYTES {
return Err(format!(
"source snapshot is {total} bytes; limit is {MAX_SNAPSHOT_TOTAL_BYTES}"
));
}
Ok(())
}
fn collect_filesystem_paths(
root: &Path,
directory: &Path,
paths: &mut Vec<PathBuf>,
) -> Result<(), String> {
for entry in fs::read_dir(directory).map_err(|error| error.to_string())? {
let entry = entry.map_err(|error| error.to_string())?;
let name = entry
.file_name()
.into_string()
.map_err(|_| "source path is not UTF-8".to_owned())?;
if directory == root && matches!(name.as_str(), ".git" | ".clusterflux" | "target") {
continue;
}
let file_type = entry.file_type().map_err(|error| error.to_string())?;
if file_type.is_dir() {
collect_filesystem_paths(root, &entry.path(), paths)?;
} else if file_type.is_file() || file_type.is_symlink() {
paths.push(entry.path());
if paths.len() > MAX_SNAPSHOT_FILES {
return Err(format!(
"source snapshot exceeds file-count limit of {MAX_SNAPSHOT_FILES}"
));
}
}
}
Ok(())
}
fn git_repository_root(project_root: &Path) -> Result<Option<PathBuf>, String> {
let output = git_command(project_root)
.args(["rev-parse", "--show-toplevel"])
.output();
let Ok(output) = output else {
return Ok(None);
};
if !output.status.success() {
return Ok(None);
}
let root = String::from_utf8(output.stdout)
.map_err(|_| "Git repository root is not UTF-8".to_owned())?;
let root = PathBuf::from(root.trim())
.canonicalize()
.map_err(|error| error.to_string())?;
Ok(Some(root))
}
fn git_text(repository_root: &Path, args: &[&str]) -> Option<String> {
let output = git_command(repository_root).args(args).output().ok()?;
if !output.status.success() {
return None;
}
String::from_utf8(output.stdout)
.ok()
.map(|value| value.trim().to_owned())
}
fn git_command(repository_root: &Path) -> Command {
let mut command = Command::new("git");
command
.arg("-C")
.arg(repository_root)
.env("GIT_OPTIONAL_LOCKS", "0")
.env("GIT_TERMINAL_PROMPT", "0");
command
}
fn slash_path(path: &Path) -> Result<String, String> {
let mut result = String::new();
for component in path.components() {
let component = component
.as_os_str()
.to_str()
.ok_or_else(|| "source path is not UTF-8".to_owned())?;
if !result.is_empty() {
result.push('/');
}
result.push_str(component);
}
Ok(result)
}
fn validate_relative_source_path(path: &str) -> Result<(), String> {
if path.is_empty()
|| path.starts_with('/')
|| path.split('/').any(|component| {
component.is_empty()
|| component == "."
|| component == ".."
|| component.contains('\0')
})
{
return Err("source snapshot path must be a safe relative path".to_owned());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn git(root: &Path, args: &[&str]) {
let output = Command::new("git")
.arg("-C")
.arg(root)
.args(args)
.env("GIT_AUTHOR_NAME", "Clusterflux Test")
.env("GIT_AUTHOR_EMAIL", "test@clusterflux.invalid")
.env("GIT_COMMITTER_NAME", "Clusterflux Test")
.env("GIT_COMMITTER_EMAIL", "test@clusterflux.invalid")
.output()
.unwrap();
assert!(
output.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
fn initialize_repository(root: &Path, message: &str) {
git(root, &["init", "--quiet"]);
git(root, &["add", "."]);
git(root, &["commit", "--quiet", "-m", message]);
}
#[test]
fn dirty_and_untracked_content_changes_snapshot_identity() {
let temp = tempfile::tempdir().unwrap();
fs::create_dir_all(temp.path().join("src")).unwrap();
fs::write(temp.path().join("src/lib.rs"), "pub fn value() -> u8 { 1 }").unwrap();
initialize_repository(temp.path(), "initial source");
let first = snapshot_project(temp.path()).unwrap();
fs::write(temp.path().join("src/lib.rs"), "pub fn value() -> u8 { 2 }").unwrap();
let dirty = snapshot_project(temp.path()).unwrap();
fs::write(
temp.path().join("fixture.c"),
"int main(void) { return 0; }",
)
.unwrap();
let untracked = snapshot_project(temp.path()).unwrap();
assert_ne!(first.digest, dirty.digest);
assert_ne!(dirty.digest, untracked.digest);
assert_eq!(untracked.provider, "git");
assert_eq!(untracked.file_count, 2);
}
#[test]
fn different_repositories_at_the_same_path_are_not_identified_by_path() {
let temp = tempfile::tempdir().unwrap();
fs::write(temp.path().join("source.txt"), "identical source content").unwrap();
initialize_repository(temp.path(), "first repository identity");
let first = snapshot_project(temp.path()).unwrap();
fs::remove_dir_all(temp.path().join(".git")).unwrap();
initialize_repository(temp.path(), "second repository identity");
let second = snapshot_project(temp.path()).unwrap();
assert_ne!(first.digest, second.digest);
assert_eq!(first.provider, "git");
assert_eq!(second.provider, "git");
}
}