use std::path::Path;
use std::path::PathBuf;
use sha2::Digest;
use sha2::Sha256;
use thiserror::Error;
use url::Url;
use crate::hash::ContentHash;
use crate::lockfile::GitCommit;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CacheKey {
prefix: PrefixKey,
commit: GitCommit,
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum PrefixKey {
GitStructured {
host: String,
org: String,
repo_with_suffix: String,
},
GitOpaque {
digest_hex: String,
},
}
impl CacheKey {
pub fn from_git_url(url: &Url, commit: &GitCommit) -> Self {
let prefix = match url.host_str() {
Some(host) => {
let mut segments = url.path().split('/').filter(|s| !s.is_empty());
match (segments.next(), segments.next()) {
(Some(org), Some(repo)) => {
let repo = repo.strip_suffix(".git").unwrap_or(repo);
let digest = hash_url(url);
let repo_with_suffix = format!("{repo}-{}", &digest[..8]);
PrefixKey::GitStructured {
host: host.to_string(),
org: org.to_string(),
repo_with_suffix,
}
}
_ => PrefixKey::GitOpaque {
digest_hex: hash_url(url),
},
}
}
None => PrefixKey::GitOpaque {
digest_hex: hash_url(url),
},
};
Self {
prefix,
commit: commit.clone(),
}
}
pub(crate) fn relative_path(&self) -> PathBuf {
let mut p = PathBuf::new();
match &self.prefix {
PrefixKey::GitStructured {
host,
org,
repo_with_suffix,
} => {
p.push(host);
p.push(org);
p.push(repo_with_suffix);
}
PrefixKey::GitOpaque { digest_hex } => {
p.push("_opaque");
p.push(digest_hex);
}
}
p.push(self.commit.as_str());
p
}
pub fn absolute_path(&self, cache_root: &Path) -> PathBuf {
cache_root.join(self.relative_path())
}
}
fn hash_url(url: &Url) -> String {
let mut h = Sha256::new();
h.update(url.as_str().as_bytes());
let bytes: [u8; 32] = h.finalize().into();
hex::encode(bytes)
}
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "will be called by the resolver once cache management is wired up"
)
)]
pub(crate) fn evict(path: &Path) -> std::io::Result<()> {
match std::fs::remove_dir_all(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "will be called by the resolver once cache management is wired up"
)
)]
pub(crate) fn verify_integrity(leaf: &Path, expected: &ContentHash) -> Result<(), IntegrityError> {
let observed =
crate::hash::hash_directory(leaf).map_err(|source| IntegrityError::Hash { source })?;
if observed != *expected {
return Err(IntegrityError::Mismatch {
expected: *expected,
observed,
});
}
Ok(())
}
#[derive(Debug, Error)]
pub(crate) enum IntegrityError {
#[error("content hash mismatch: expected `{expected}`, observed `{observed}`")]
Mismatch {
expected: ContentHash,
observed: ContentHash,
},
#[error(transparent)]
Hash {
#[from]
source: crate::hash::HashError,
},
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::tempdir;
use super::*;
fn commit() -> GitCommit {
GitCommit::try_from("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2".to_string()).unwrap()
}
#[test]
fn structured_layout_for_github_url() {
let url = Url::parse("https://github.com/openwdl/tasks").unwrap();
let key = CacheKey::from_git_url(&url, &commit());
let parts: Vec<_> = key
.relative_path()
.iter()
.map(|c| c.to_str().unwrap().to_string())
.collect();
assert_eq!(parts.len(), 4);
assert_eq!(parts[0], "github.com");
assert_eq!(parts[1], "openwdl");
assert!(
parts[2].starts_with("tasks-"),
"expected `tasks-<digest8>`, got: {parts:?}"
);
assert_eq!(parts[3], "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2");
}
#[test]
fn opaque_layout_when_url_lacks_org_repo() {
let url = Url::parse("https://example.com/").unwrap();
let key = CacheKey::from_git_url(&url, &commit());
let parts: Vec<_> = key
.relative_path()
.iter()
.map(|c| c.to_str().unwrap().to_string())
.collect();
assert_eq!(parts[0], "_opaque");
assert_eq!(parts[2], "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2");
assert_eq!(parts[1].len(), 64);
assert!(parts[1].chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn strips_dot_git_suffix() {
let url = Url::parse("https://github.com/openwdl/tasks.git").unwrap();
let key = CacheKey::from_git_url(&url, &commit());
let parts: Vec<_> = key
.relative_path()
.iter()
.map(|c| c.to_str().unwrap().to_string())
.collect();
assert!(parts[2].starts_with("tasks-"), "got: {parts:?}");
}
#[test]
fn nested_repository_urls_do_not_collide() {
let url_short = Url::parse("https://gitlab.example/x/y").unwrap();
let url_long = Url::parse("https://gitlab.example/x/y/z").unwrap();
let k_short = CacheKey::from_git_url(&url_short, &commit());
let k_long = CacheKey::from_git_url(&url_long, &commit());
assert_ne!(
k_short.relative_path(),
k_long.relative_path(),
"nested repository URLs must produce distinct cache keys"
);
}
#[test]
fn evict_removes_leaf() {
let dir = tempdir().unwrap();
let leaf = dir.path().join("leaf");
fs::create_dir_all(&leaf).unwrap();
fs::write(leaf.join("file"), b"x").unwrap();
evict(&leaf).unwrap();
assert!(!leaf.exists());
}
#[test]
fn evict_is_noop_when_missing() {
let dir = tempdir().unwrap();
evict(&dir.path().join("never-existed")).unwrap();
}
#[test]
fn verify_integrity_passes_on_match() {
let dir = tempdir().unwrap();
let leaf = dir.path().join("leaf");
fs::create_dir_all(&leaf).unwrap();
fs::write(leaf.join("a.wdl"), b"hello").unwrap();
let hash = crate::hash::hash_directory(&leaf).unwrap();
verify_integrity(&leaf, &hash).unwrap();
}
#[test]
fn verify_integrity_fails_on_mismatch() {
let dir = tempdir().unwrap();
let leaf = dir.path().join("leaf");
fs::create_dir_all(&leaf).unwrap();
fs::write(leaf.join("a.wdl"), b"hello").unwrap();
let bad: ContentHash =
"sha256:0000000000000000000000000000000000000000000000000000000000000000"
.parse()
.unwrap();
let err = verify_integrity(&leaf, &bad).unwrap_err();
assert!(matches!(err, IntegrityError::Mismatch { .. }));
}
}