use std::path::Path;
use sha2::{Digest as _, Sha256};
use anyhow::Context as _;
use crate::cas::Digest;
use crate::manifest::GenerationManifest;
use crate::snapshot::RepoSnapshot;
const REPO_IDENTITY_VERSION: u32 = 1;
/// length-prefixed tagged fields can't collide across field boundaries
pub mod hash_fields {
fn tagged(hasher: &mut impl sha2::Digest, tag: &[u8], bytes: &[u8]) {
hasher.update(tag);
hasher.update((bytes.len() as u64).to_le_bytes());
hasher.update(bytes);
}
pub fn u32(hasher: &mut impl sha2::Digest, value: u32) {
tagged(hasher, b"u32", &value.to_le_bytes());
}
pub fn bool_field(hasher: &mut impl sha2::Digest, tag: &[u8], value: bool) {
tagged(hasher, tag, &[u8::from(value)]);
}
pub fn field(hasher: &mut impl sha2::Digest, tag: &[u8], value: Option<&str>) {
match value {
Some(value) => tagged(hasher, tag, value.as_bytes()),
None => tagged(hasher, tag, b"\0absent"),
}
}
pub fn text(hasher: &mut impl sha2::Digest, tag: &[u8], value: &str) {
tagged(hasher, tag, value.as_bytes());
}
}
pub fn repo_key(repo_did: &str) -> String {
use std::fmt::Write as _;
let mut key = String::with_capacity(repo_did.len());
for byte in repo_did.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' => key.push(byte as char),
_ => {
let _ = write!(&mut key, "%{byte:02X}");
}
}
}
key
}
pub fn repo_token(repo_did: &str) -> String {
Digest::of_bytes(repo_did.as_bytes()).hex()[..32].to_string()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GlobalInputs {
pub config_digest: String,
}
impl GlobalInputs {
pub fn of_config(config: &crate::config::ValidatedConfig) -> Self {
Self {
config_digest: config.config_digest(),
}
}
pub fn matches_manifest(&self, manifest: &GenerationManifest) -> bool {
manifest.global_input_digest.as_deref() == Some(self.config_digest.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RepoOutcome {
Unchanged { generation: String },
Rebuild,
}
pub fn plan_repo(
snapshot: &RepoSnapshot,
global: &GlobalInputs,
active: Option<&ActiveGeneration>,
) -> RepoOutcome {
let Some(active) = active else {
return RepoOutcome::Rebuild;
};
if !global.matches_manifest(&active.manifest) {
return RepoOutcome::Rebuild;
}
let identity = repo_identity(snapshot, global);
let matches =
active.manifest.repos.iter().any(|repo| {
repo.repo_did == snapshot.repo_did.as_str() && repo.fingerprint == identity
});
if matches {
RepoOutcome::Unchanged {
generation: active.manifest.generation_id.clone(),
}
} else {
RepoOutcome::Rebuild
}
}
pub fn repo_identity(snapshot: &RepoSnapshot, global: &GlobalInputs) -> String {
let mut hasher = Sha256::new();
hash_fields::u32(&mut hasher, REPO_IDENTITY_VERSION);
// seal versions are hashed because a bump changes the served output, not the inputs
hash_fields::u32(&mut hasher, crate::git_xrpc::SEAL_VERSION);
hash_fields::u32(&mut hasher, crate::atproto::pool::SEAL_VERSION);
hash_fields::text(&mut hasher, b"snapshot", &snapshot.snapshot_identity());
hash_fields::field(&mut hasher, b"owner", Some(snapshot.owner.as_str()));
hash_fields::field(&mut hasher, b"rkey", Some(snapshot.rkey.as_str()));
hash_fields::field(
&mut hasher,
b"name",
snapshot.name.as_ref().map(|n| n.as_str()),
);
hash_fields::text(&mut hasher, b"config", &global.config_digest);
Digest::from_raw(hasher.finalize().into()).hex()
}
#[derive(Debug, Clone)]
pub struct ActiveGeneration {
pub manifest: GenerationManifest,
}
pub fn load_active_generation(output_root: &Path) -> anyhow::Result<Option<ActiveGeneration>> {
let Some(link) = std::fs::read_link(output_root.join("current")).ok() else {
return Ok(None);
};
let generation_id = link
.file_name()
.and_then(|name| name.to_str())
.context("current pointer has no generation name")?
.to_string();
let generation_dir = output_root.join("generations").join(&generation_id);
if !generation_dir
.join(crate::generate::COMPLETION_FILE)
.is_file()
{
return Ok(None);
}
let manifest = GenerationManifest::load(&generation_dir)
.with_context(|| format!("load active generation manifest {generation_id}"))?;
Ok(Some(ActiveGeneration { manifest }))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::{BuildStats, RepoEntry};
fn fixture_snapshot(symbolic: Option<&str>) -> RepoSnapshot {
use knot_types::{ObjectFormat, OwnerDid, RepoDid, RepoRkey};
RepoSnapshot {
repo_did: RepoDid::new("did:plc:abc").unwrap(),
owner: OwnerDid::new("did:plc:owner").unwrap(),
rkey: RepoRkey::new("abc").unwrap(),
name: None,
head_capture: crate::snapshot::HeadCapture {
resolved: Some("1111111111111111111111111111111111111111".to_string()),
symbolic: symbolic.map(|s| s.to_string()),
unborn: false,
},
public_refs: vec![crate::snapshot::PublicRef {
name: "refs/heads/main".to_string(),
raw: "1111111111111111111111111111111111111111".to_string(),
peeled: None,
}],
cob_refs: Vec::new(),
object_format: ObjectFormat::SHA1,
}
}
fn fixture_manifest_with_repo(
did: &str,
fingerprint: &str,
digest: Option<String>,
) -> GenerationManifest {
GenerationManifest {
rebuilt_repos: Vec::new(),
generation_id: "a".repeat(64),
generator_profile: "test".to_string(),
conf_template: "test".to_string(),
knot_did: "did:web:knot.test".to_string(),
hostname: "knot.test".to_string(),
owner: "did:plc:owner".to_string(),
static_version: "test".to_string(),
built_by: String::new(),
profile: serde_json::json!({}),
repos: vec![RepoEntry {
release_id: None,
repo_did: did.to_string(),
owner: "did:plc:owner".to_string(),
rkey: "abc".to_string(),
name: None,
head: Some("1111111111111111111111111111111111111111".to_string()),
public_refs: vec![(
"refs/heads/main".to_string(),
"1111111111111111111111111111111111111111".to_string(),
)],
cob_refs: Vec::new(),
object_format: "sha1".to_string(),
fingerprint: fingerprint.to_string(),
git_export_hash: None,
fetch_bases: Vec::new(),
shallow_branches: Vec::new(),
shallow_head: None,
moved_wants: Vec::new(),
}],
global_input_digest: digest,
handles_digest: None,
stats: BuildStats::default(),
}
}
fn digest_of(value: &str) -> String {
Digest::of_bytes(value.as_bytes()).hex()
}
#[test]
fn unchanged_requires_global_and_repo_inputs_to_match() {
let global = GlobalInputs {
config_digest: digest_of("cfg-a"),
};
let snap = fixture_snapshot(Some("refs/heads/main"));
let identity = repo_identity(&snap, &global);
let manifest = fixture_manifest_with_repo(
"did:plc:abc",
&identity,
Some(global.config_digest.clone()),
);
let active = ActiveGeneration { manifest };
assert_eq!(
plan_repo(&snap, &global, Some(&active)),
RepoOutcome::Unchanged {
generation: "a".repeat(64)
}
);
let new_global = GlobalInputs {
config_digest: digest_of("cfg-b"),
};
assert_eq!(
plan_repo(&snap, &new_global, Some(&active)),
RepoOutcome::Rebuild
);
let legacy = fixture_manifest_with_repo("did:plc:abc", &identity, None);
let legacy_active = ActiveGeneration { manifest: legacy };
assert_eq!(
plan_repo(&snap, &global, Some(&legacy_active)),
RepoOutcome::Rebuild
);
assert_eq!(plan_repo(&snap, &global, None), RepoOutcome::Rebuild);
}
#[test]
fn same_oids_with_different_symbolic_head_are_not_unchanged() {
let global = GlobalInputs {
config_digest: digest_of("cfg-a"),
};
let on_main = fixture_snapshot(Some("refs/heads/main"));
let on_other = fixture_snapshot(Some("refs/heads/other"));
assert_ne!(on_main.snapshot_identity(), on_other.snapshot_identity());
let identity = repo_identity(&on_main, &global);
let manifest = fixture_manifest_with_repo(
"did:plc:abc",
&identity,
Some(global.config_digest.clone()),
);
let active = ActiveGeneration { manifest };
assert_eq!(
plan_repo(&on_other, &global, Some(&active)),
RepoOutcome::Rebuild
);
assert_eq!(
plan_repo(&on_main, &global, Some(&active)),
RepoOutcome::Unchanged {
generation: "a".repeat(64)
}
);
}
#[test]
fn repo_identity_covers_alias_and_config_inputs() {
let global = GlobalInputs {
config_digest: digest_of("cfg-a"),
};
let mut renamed = fixture_snapshot(Some("refs/heads/main"));
renamed.name = Some(knot_types::RepoName::new("display-name").unwrap());
assert_ne!(
repo_identity(&fixture_snapshot(Some("refs/heads/main")), &global),
repo_identity(&renamed, &global),
"display-name change must invalidate even with identical OIDs"
);
assert_ne!(
repo_identity(&fixture_snapshot(Some("refs/heads/main")), &global),
repo_identity(
&fixture_snapshot(Some("refs/heads/main")),
&GlobalInputs {
config_digest: digest_of("cfg-b")
}
),
);
}
#[test]
fn repo_key_is_stable_and_injective() {
assert_eq!(repo_key("did:plc:abc123"), "did%3Aplc%3Aabc123");
assert_eq!(repo_key("did:web:example.com"), "did%3Aweb%3Aexample.com");
assert_eq!(repo_key("did:plc:a:b"), "did%3Aplc%3Aa%3Ab");
assert_eq!(repo_key("did:x%3Aa"), "did%3Ax%253Aa");
assert_ne!(repo_key("did:plc:a:b"), repo_key("did:plc:a%3Ab"));
}
}