use std::collections::{BTreeMap, BTreeSet}; use gix::bstr::{BStr, ByteSlice}; use serde::Serialize; use knot_cob::{Change, CobHome, Payload, parse_cob_ref}; use knot_git::Repo; use knot_runtime::Signer; use knot_types::{ActorId, ChangeId, CobId, Oid, RefName, TypeName, UnixSeconds, lowercase_hex}; use crate::errors::CliError; pub const COBS_PREFIX: &str = "refs/cobs/"; pub const PAYLOAD_BLOB: &str = "payload"; pub const TYPE_HEADER: &str = "cob-type"; pub const AUTHOR_HEADER: &str = "cob-author"; pub const SIG_HEADER: &str = "cob-sig"; pub const MAX_GRAPH_CHANGES: usize = 100_000; pub fn cob_list_types(repo: &Repo) -> Result, CliError> { let mut types = BTreeSet::new(); for record in repo.references()? { if let Some((type_name, _)) = parse_cob_ref(record.name.as_str()) { types.insert(type_name); } } Ok(types) } pub fn cob_list_objects(repo: &Repo, type_name: &TypeName) -> Result, CliError> { let prefix = format!("{COBS_PREFIX}{}/", type_name.as_str()); Ok(repo .references()? .into_iter() .filter_map(|record| { let rest = record.name.as_str().strip_prefix(&prefix)?; Oid::from_hex(rest).ok().map(CobId::new) }) .collect()) } pub fn cob_resolve_tip( repo: &Repo, type_name: &TypeName, object: CobId, ) -> Result, CliError> { let name = cob_ref_name(type_name, object)?; Ok(repo.find_ref(&name)?) } pub fn cob_ref_name(type_name: &TypeName, object: CobId) -> Result { let raw = format!( "{COBS_PREFIX}{}/{}", type_name.as_str(), object.oid().to_hex() ); RefName::new(raw.as_str()).map_err(|e| CliError::Other(format!("invalid ref name: {e}"))) } pub fn cob_read_change(repo: &Repo, id: ChangeId) -> Result { let oid = id.oid(); let git = repo.git(); let data = git .find_object(oid.object_id()) .map_err(|e| { CliError::Other(format!( "malformed change {oid_hex}: {e}", oid_hex = oid.to_hex() )) })? .detach() .data; let commit = gix::objs::CommitRef::from_bytes(&data, git.object_hash()).map_err(|e| { CliError::Other(format!( "malformed change {oid_hex}: {e}", oid_hex = oid.to_hex() )) })?; let revision = Oid::from(commit.tree()); let parents: Vec = commit .parents() .map(|p| ChangeId::new(Oid::from(p))) .collect(); let timestamp = UnixSeconds::new( commit .time() .map_err(|e| { CliError::Other(format!( "malformed change {oid_hex}: {e}", oid_hex = oid.to_hex() )) })? .seconds, ); let type_raw = commit.extra_headers().find(TYPE_HEADER).ok_or_else(|| { CliError::Other(format!( "change {oid_hex} missing cob-type header", oid_hex = oid.to_hex() )) })?; let type_name = TypeName::new( type_raw .to_str() .map_err(|e| CliError::Other(format!("invalid cob-type header: {e}")))?, ) .map_err(|e| CliError::Other(format!("invalid cob-type header: {e}")))?; let author_raw = commit.extra_headers().find(AUTHOR_HEADER).ok_or_else(|| { CliError::Other(format!( "change {oid_hex} missing cob-author header", oid_hex = oid.to_hex() )) })?; let author = ActorId::new( author_raw .to_str() .map_err(|e| CliError::Other(format!("invalid cob-author header: {e}")))?, ) .map_err(|e| CliError::Other(format!("invalid cob-author header: {e}")))?; let signature_raw = commit.extra_headers().find(SIG_HEADER).ok_or_else(|| { CliError::Other(format!( "change {oid_hex} missing cob-sig header", oid_hex = oid.to_hex() )) })?; let signature_bytes = knot_types::decode_hex(signature_raw) .ok_or_else(|| CliError::Other("invalid cob-sig header".into()))?; let payload = cob_read_payload(repo, revision)?; Ok(Change { id, revision, parents, type_name, author, signature: knot_runtime::Signature::from_bytes(signature_bytes), payload: Payload::new(payload), timestamp, }) } pub fn cob_read_payload(repo: &Repo, revision: Oid) -> Result, CliError> { let git = repo.git(); let data = git .find_object(revision.object_id()) .map_err(|e| CliError::Other(format!("malformed change: {e}")))? .detach() .data; let tree = gix::objs::TreeRef::from_bytes(&data, git.object_hash()) .map_err(|e| CliError::Other(format!("malformed change tree: {e}")))?; let payload_oid = tree .entries .iter() .find(|entry| entry.filename == BStr::new(PAYLOAD_BLOB)) .map(|entry| entry.oid.to_owned()) .ok_or_else(|| CliError::Other("missing payload blob in change tree".into()))?; let payload = git .find_object(payload_oid) .map_err(|e| CliError::Other(format!("missing payload blob: {e}")))? .detach() .data; Ok(payload) } pub fn cob_collect( repo: &Repo, tip: ChangeId, limit: usize, stop: Option, ) -> Result, CliError> { let mut frontier = vec![tip]; let mut seen: BTreeMap = BTreeMap::new(); let walk = { let mut step = || -> Option> { let head = frontier.pop()?; if Some(head) == stop || seen.contains_key(&head) { return Some(Ok(())); } if seen.len() >= limit { return None; } match cob_read_change(repo, head) { Ok(change) => { frontier.extend(change.parents.iter().copied()); seen.insert(head, change); Some(Ok(())) } Err(e) => Some(Err(e)), } }; std::iter::from_fn(&mut step).try_for_each(|r| r) }; walk?; if seen.len() >= limit && stop.is_none() { return Err(CliError::Other("history too long".into())); } Ok(seen) } pub fn causal_order(changes: &BTreeMap) -> Vec { use std::cmp::Reverse; use std::collections::BinaryHeap; let mut indegree: BTreeMap = changes .values() .map(|c| { let present = c.parents.iter().filter(|p| changes.contains_key(p)).count(); (c.id, present) }) .collect(); let children: BTreeMap> = changes.values().fold(BTreeMap::new(), |mut acc, c| { c.parents .iter() .filter(|p| changes.contains_key(p)) .for_each(|p| acc.entry(*p).or_default().push(c.id)); acc }); let mut ready: BinaryHeap> = changes .values() .filter(|c| indegree[&c.id] == 0) .map(|c| Reverse(c.sort_key())) .collect(); std::iter::from_fn(move || { let Reverse((_, id)) = ready.pop()?; children.get(&id).into_iter().flatten().for_each(|child| { let degree = indegree .get_mut(child) .expect("every child has an indegree entry"); *degree -= 1; if *degree == 0 { ready.push(Reverse(changes[child].sort_key())); } }); Some(id) }) .collect() } pub fn cob_write_change( repo: &Repo, payload: &[u8], parents: &[ChangeId], type_name: &TypeName, author: &ActorId, signer: &dyn Signer, home: &CobHome, timestamp: UnixSeconds, object: Option, ) -> Result { let git = repo.git(); let payload_oid = git .write_blob(payload) .map_err(|e| CliError::Other(format!("write payload blob: {e}")))? .detach(); let tree = gix::objs::Tree { entries: vec![gix::objs::tree::Entry { mode: gix::objs::tree::EntryKind::Blob.into(), filename: PAYLOAD_BLOB.into(), oid: payload_oid, }], }; let revision = git .write_object(tree) .map_err(|e| CliError::Other(format!("write tree: {e}")))? .detach(); let revision_oid = Oid::from(revision); let signing = signing_bytes( home, revision_oid, parents, type_name, author, timestamp, object, ); let signature = signer.sign(&signing); let sig_hex = lowercase_hex(signature.as_bytes()); let parent_ids: Vec<_> = parents.iter().map(|p| p.oid().object_id()).collect(); let commit = gix::objs::Commit { tree: revision, parents: parent_ids.into(), author: knot_identity(timestamp), committer: knot_identity(timestamp), encoding: None, message: Vec::new().into(), extra_headers: vec![ (TYPE_HEADER.into(), type_name.as_str().into()), (AUTHOR_HEADER.into(), author.as_str().into()), (SIG_HEADER.into(), sig_hex.as_bytes().into()), ], }; let id = git .write_object(commit) .map_err(|e| CliError::Other(format!("write commit: {e}")))? .detach(); Ok(ChangeId::new(Oid::from(id))) } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct SignedChange<'a> { context: &'static str, home_kind: &'a str, home: &'a str, revision: Oid, parents: &'a [ChangeId], type_name: &'a TypeName, author: &'a ActorId, timestamp: UnixSeconds, #[serde(skip_serializing_if = "Option::is_none")] object: Option, } fn signing_bytes( home: &CobHome, revision: Oid, parents: &[ChangeId], type_name: &TypeName, author: &ActorId, timestamp: UnixSeconds, object: Option, ) -> Vec { let home_kind = match home { CobHome::Repo(_) => "repo", CobHome::Knot(_) => "knot", }; let object_binding = if parents.is_empty() { None } else { object }; let view = SignedChange { context: "sh.tangled.knot.cob.change.v1", home_kind, home: home.as_str(), revision, parents, type_name, author, timestamp, object: object_binding, }; serde_ipld_dagcbor::to_vec(&view).expect("signing view always encodes") } fn knot_identity(time: UnixSeconds) -> gix::actor::Signature { gix::actor::Signature { name: "knot".into(), email: "noreply@knot".into(), time: gix::date::Time::new(time.get(), 0), } }