use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::io::Read;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::Path;
use anyhow::{Context, Result};
use sha2::{Digest, Sha256};
use super::super::{format::Task, repository};
use crate::prompt;
pub(super) struct Snapshot {
base: String,
change: String,
inputs: BTreeSet<String>,
digest: Vec<u8>,
}
impl Snapshot {
pub fn capture(repo: &Path, task: &Task) -> Result<Self> {
let inputs = task.trial.as_ref().context("missing Trial inputs")?;
let mut snapshot = Self {
base: format!("refs/heads/{}", inputs.base),
change: repository::git(repo, &["rev-parse", "HEAD"])?,
inputs: task.inputs.keys().cloned().collect(),
digest: Vec::new(),
};
snapshot.inputs.insert(prompt::ISSUE_FILE.into());
snapshot.digest = snapshot.digest(repo)?;
Ok(snapshot)
}
pub fn unchanged(&self, repo: &Path) -> Result<bool> {
Ok(self.digest(repo)? == self.digest)
}
fn digest(&self, repo: &Path) -> Result<Vec<u8>> {
let mut digest = Sha256::new();
hash(
&mut digest,
repository::git(repo, &["rev-parse", &self.base])?.as_bytes(),
);
hash(
&mut digest,
repository::git(
repo,
&[
"rev-list",
"--parents",
"--no-walk",
&self.base,
&self.change,
],
)?
.as_bytes(),
);
let objects = repository::git(
repo,
&[
"rev-list",
"--objects",
"--no-object-names",
"--no-walk",
&self.base,
&self.change,
],
)?;
hash(
&mut digest,
&repository::bytes(
repo,
&["cat-file", "--batch"],
Some(format!("{objects}\n").as_bytes()),
)?,
);
let objects: BTreeSet<_> = objects.lines().collect();
let replacements = repository::git(
repo,
&[
"for-each-ref",
"--format=%(refname:strip=2) %(objectname)",
"refs/replace/",
],
)?;
for replacement in replacements.lines() {
if replacement
.split_once(' ')
.is_some_and(|(id, _)| objects.contains(id))
{
hash(&mut digest, replacement.as_bytes());
}
}
for name in &self.inputs {
hash_input(&mut digest, repo, name)?;
}
Ok(digest.finalize().to_vec())
}
}
pub(super) fn tree_digest(root: &Path) -> Result<Vec<u8>> {
let mut files = BTreeMap::new();
collect(root, "", &mut files)?;
let mut digest = Sha256::new();
for (path, bytes) in files {
hash(&mut digest, path.as_bytes());
hash(&mut digest, &bytes);
}
Ok(digest.finalize().to_vec())
}
fn collect(dir: &Path, prefix: &str, out: &mut BTreeMap<String, Vec<u8>>) -> Result<()> {
let mut entries = Vec::new();
for entry in fs::read_dir(dir)? {
entries.push(entry?);
}
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
let name = entry.file_name();
let name = name.to_string_lossy();
let rel = if prefix.is_empty() {
name.into_owned()
} else {
format!("{prefix}/{name}")
};
let path = entry.path();
let metadata = fs::symlink_metadata(&path)?;
let mut record = metadata.permissions().mode().to_le_bytes().to_vec();
if metadata.is_dir() {
record.extend_from_slice(b"dir");
out.insert(rel.clone(), record);
collect(&path, &rel, out)?;
} else if metadata.is_symlink() {
record.extend_from_slice(b"link");
record.extend_from_slice(fs::read_link(&path)?.as_os_str().as_encoded_bytes());
out.insert(rel, record);
} else if metadata.is_file() {
record.extend_from_slice(b"file");
match read_regular(&path)? {
Some(bytes) => record.extend(bytes),
None => record.extend_from_slice(b"not-regular"),
}
out.insert(rel, record);
} else {
record.extend_from_slice(b"other");
out.insert(rel, record);
}
}
Ok(())
}
fn hash_input(digest: &mut Sha256, repo: &Path, name: &str) -> Result<()> {
let path = repo.join(name);
match fs::symlink_metadata(&path) {
Ok(metadata) => {
hash(digest, name.as_bytes());
hash(digest, &metadata.permissions().mode().to_le_bytes());
if metadata.is_symlink() {
hash(digest, fs::read_link(&path)?.as_os_str().as_encoded_bytes());
}
if metadata.is_file() || metadata.is_symlink() {
match read_regular(&path)? {
Some(bytes) => hash(digest, &bytes),
None => hash(digest, b"not-regular"),
}
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
hash(digest, name.as_bytes());
hash(digest, b"missing");
}
Err(error) => return Err(error.into()),
}
Ok(())
}
fn read_regular(path: &Path) -> Result<Option<Vec<u8>>> {
let mut file = fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NONBLOCK | libc::O_NOCTTY | libc::O_CLOEXEC)
.open(path)?;
if !file.metadata()?.file_type().is_file() {
return Ok(None);
}
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)?;
Ok(Some(bytes))
}
fn hash(digest: &mut Sha256, bytes: &[u8]) {
digest.update((bytes.len() as u64).to_le_bytes());
digest.update(bytes);
}