use std::collections::BTreeMap;
use std::path::Path;
use std::sync::Mutex;
use sha2::{Digest, Sha256};
pub trait GitPrefetcher: Send + Sync {
fn prefetch(&self, url: &str, rev: &str) -> Result<PrefetchedHash, PrefetchError>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PrefetchedHash {
pub sri: String,
pub raw: [u8; 32],
}
impl PrefetchedHash {
#[must_use]
pub fn from_digest(raw: [u8; 32]) -> Self {
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD.encode(raw);
Self {
sri: format!("sha256-{b64}"),
raw,
}
}
}
#[derive(thiserror::Error)]
#[gen_macros::fsm(label = "gen.cargo.prefetch-error")]
pub enum PrefetchError {
#[error("git fetch failed for {url}#{rev}: {reason}")]
Fetch {
url: String,
rev: String,
reason: String,
},
#[error("NAR serialization/hash failed for {url}#{rev}: {reason}")]
NarHash {
url: String,
rev: String,
reason: String,
},
#[error("temp dir creation failed: {reason}")]
TempDir { reason: String },
#[error("MockPrefetcher: no mapping registered for {url}#{rev}")]
MockMappingMissing { url: String, rev: String },
}
pub struct GitCliPrefetcher;
impl GitCliPrefetcher {
#[must_use]
pub fn new() -> Self {
Self
}
}
impl Default for GitCliPrefetcher {
fn default() -> Self {
Self::new()
}
}
impl GitPrefetcher for GitCliPrefetcher {
fn prefetch(&self, url: &str, rev: &str) -> Result<PrefetchedHash, PrefetchError> {
let clean_url = url.split('?').next().unwrap_or(url);
let tmp = tempfile::Builder::new()
.prefix("gen-cargo-prefetch-")
.tempdir()
.map_err(|e| PrefetchError::TempDir {
reason: e.to_string(),
})?;
let work = tmp.path();
git_clone_at_rev(clean_url, rev, work).map_err(|reason| PrefetchError::Fetch {
url: clean_url.into(),
rev: rev.into(),
reason,
})?;
let dot_git = work.join(".git");
if dot_git.exists() {
std::fs::remove_dir_all(&dot_git).map_err(|e| PrefetchError::NarHash {
url: clean_url.into(),
rev: rev.into(),
reason: format!("remove .git: {e}"),
})?;
}
let digest = nar_sha256(work).map_err(|reason| PrefetchError::NarHash {
url: clean_url.into(),
rev: rev.into(),
reason,
})?;
Ok(PrefetchedHash::from_digest(digest))
}
}
fn git_clone_at_rev(url: &str, rev: &str, dest: &Path) -> Result<(), String> {
use std::process::Command;
let run = |args: &[&str], cwd: Option<&Path>| -> Result<(), String> {
let mut cmd = Command::new("git");
cmd.args(args);
if let Some(c) = cwd {
cmd.current_dir(c);
}
let output = cmd
.output()
.map_err(|e| format!("spawn `git {}`: {e}", args.join(" ")))?;
if !output.status.success() {
return Err(format!(
"`git {}` exited {}: {}",
args.join(" "),
output.status,
String::from_utf8_lossy(&output.stderr).trim()
));
}
Ok(())
};
run(&["init", "--quiet", dest.to_str().unwrap()], None)?;
run(&["remote", "add", "origin", url], Some(dest))?;
let shallow_fetch =
run(&["fetch", "--quiet", "--depth=1", "origin", rev], Some(dest));
if shallow_fetch.is_err() {
run(&["fetch", "--quiet", "origin", rev], Some(dest))?;
}
run(
&[
"-c",
"advice.detachedHead=false",
"checkout",
"--quiet",
"FETCH_HEAD",
],
Some(dest),
)?;
Ok(())
}
fn nar_sha256(path: &Path) -> Result<[u8; 32], String> {
let encoder =
nix_nar::Encoder::new(path).map_err(|e| format!("nix-nar Encoder: {e}"))?;
let mut reader = std::io::BufReader::new(encoder);
let mut writer = Sha256Writer::new();
std::io::copy(&mut reader, &mut writer).map_err(|e| format!("nar copy: {e}"))?;
Ok(writer.finalize())
}
struct Sha256Writer {
hasher: Sha256,
}
impl Sha256Writer {
fn new() -> Self {
Self {
hasher: Sha256::new(),
}
}
fn finalize(self) -> [u8; 32] {
self.hasher.finalize().into()
}
}
impl std::io::Write for Sha256Writer {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.hasher.update(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[derive(Default)]
pub struct MockPrefetcher {
mappings: Mutex<BTreeMap<(String, String), PrefetchedHash>>,
}
impl MockPrefetcher {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn insert(
&self,
url: impl Into<String>,
rev: impl Into<String>,
hash: PrefetchedHash,
) {
self.mappings
.lock()
.expect("MockPrefetcher mutex poisoned")
.insert((url.into(), rev.into()), hash);
}
}
impl GitPrefetcher for MockPrefetcher {
fn prefetch(&self, url: &str, rev: &str) -> Result<PrefetchedHash, PrefetchError> {
let clean_url = url.split('?').next().unwrap_or(url);
let key = (clean_url.to_string(), rev.to_string());
self.mappings
.lock()
.expect("MockPrefetcher mutex poisoned")
.get(&key)
.cloned()
.ok_or_else(|| PrefetchError::MockMappingMissing {
url: clean_url.into(),
rev: rev.into(),
})
}
}
#[must_use]
pub fn default_prefetcher() -> Box<dyn GitPrefetcher> {
Box::new(GitCliPrefetcher::new())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn sri_encoding_is_canonical() {
let zero = PrefetchedHash::from_digest([0u8; 32]);
assert_eq!(
zero.sri,
"sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
);
}
#[test]
fn mock_returns_registered_hash() {
let mock = MockPrefetcher::new();
let hash = PrefetchedHash::from_digest([1u8; 32]);
mock.insert("https://example.com/repo", "deadbeef", hash.clone());
let got = mock.prefetch("https://example.com/repo", "deadbeef").unwrap();
assert_eq!(got, hash);
}
#[test]
fn mock_strips_cargo_query_suffix() {
let mock = MockPrefetcher::new();
let hash = PrefetchedHash::from_digest([2u8; 32]);
mock.insert("https://example.com/repo", "rev1", hash.clone());
let got = mock
.prefetch("https://example.com/repo?rev=rev1", "rev1")
.unwrap();
assert_eq!(got, hash);
}
#[test]
fn mock_missing_returns_typed_error() {
let mock = MockPrefetcher::new();
let err = mock
.prefetch("https://example.com/repo", "rev1")
.unwrap_err();
matches!(
err,
PrefetchError::MockMappingMissing { ref url, ref rev }
if url == "https://example.com/repo" && rev == "rev1"
);
}
#[test]
fn nar_sha256_of_empty_dir_is_deterministic() {
let tmp = tempfile::tempdir().unwrap();
let first = nar_sha256(tmp.path()).unwrap();
let second = nar_sha256(tmp.path()).unwrap();
assert_eq!(first, second);
}
#[test]
fn nar_sha256_changes_when_file_added() {
let tmp = tempfile::tempdir().unwrap();
let empty_digest = nar_sha256(tmp.path()).unwrap();
fs::write(tmp.path().join("hello.txt"), b"world").unwrap();
let one_file_digest = nar_sha256(tmp.path()).unwrap();
assert_ne!(empty_digest, one_file_digest);
}
#[test]
fn nar_sha256_changes_when_contents_change() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("a.txt"), b"alpha").unwrap();
let a = nar_sha256(tmp.path()).unwrap();
fs::write(tmp.path().join("a.txt"), b"beta").unwrap();
let b = nar_sha256(tmp.path()).unwrap();
assert_ne!(a, b);
}
#[test]
fn nar_sha256_independent_of_directory_walk_order() {
let tmp_a = tempfile::tempdir().unwrap();
fs::write(tmp_a.path().join("z.txt"), b"z").unwrap();
fs::write(tmp_a.path().join("a.txt"), b"a").unwrap();
let da = nar_sha256(tmp_a.path()).unwrap();
let tmp_b = tempfile::tempdir().unwrap();
fs::write(tmp_b.path().join("a.txt"), b"a").unwrap();
fs::write(tmp_b.path().join("z.txt"), b"z").unwrap();
let db = nar_sha256(tmp_b.path()).unwrap();
assert_eq!(da, db);
}
#[test]
fn default_prefetcher_returns_gix() {
let _: Box<dyn GitPrefetcher> = default_prefetcher();
}
}