1use sha2::{Digest, Sha256};
9use std::collections::{BTreeMap, BTreeSet};
10use std::fmt::Write as _;
11
12use crate::hbc_schema_links::{decode_linked_program, LinkedProgram, SchemaCoordinate};
13
14const COMPONENT_EPOCH: &str = ":std.typed.catalog/component-v2";
15const HASH_PREFIX: &str = "sha256:";
16const DIGEST_HEX_LENGTH: usize = 64;
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct CatalogEntry {
21 pub coordinate: SchemaCoordinate,
22 pub dependencies: Vec<SchemaCoordinate>,
23}
24
25impl CatalogEntry {
26 pub fn new(
27 coordinate: SchemaCoordinate,
28 dependencies: Vec<SchemaCoordinate>,
29 ) -> Result<Self, String> {
30 validate_coordinate(&coordinate)?;
31 Ok(Self {
32 coordinate,
33 dependencies: canonical_coordinates(&dependencies, "catalog entry dependencies")?,
34 })
35 }
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct CatalogComponent {
41 pub id: String,
42 pub members: Vec<SchemaCoordinate>,
43 pub dependencies: Vec<String>,
44}
45
46impl CatalogComponent {
47 pub fn new(
48 id: impl Into<String>,
49 members: Vec<SchemaCoordinate>,
50 dependencies: Vec<String>,
51 ) -> Result<Self, String> {
52 let id = id.into();
53 validate_hash(&id, "schema catalog component id")?;
54 let members = canonical_coordinates(&members, "schema catalog component members")?;
55 if members.is_empty() {
56 return Err("schema catalog component requires at least one member".into());
57 }
58 Ok(Self {
59 id,
60 members,
61 dependencies: canonical_component_dependencies(&dependencies)?,
62 })
63 }
64}
65
66#[derive(Debug, Clone)]
69pub struct AdmittedCatalog {
70 entries: BTreeMap<SchemaCoordinate, CatalogEntry>,
71 components: BTreeMap<String, CatalogComponent>,
72 owners: BTreeMap<SchemaCoordinate, String>,
73 component_order: Vec<String>,
74}
75
76impl AdmittedCatalog {
77 pub fn entry(&self, coordinate: &SchemaCoordinate) -> Option<&CatalogEntry> {
78 self.entries.get(coordinate)
79 }
80
81 pub fn component_for(&self, coordinate: &SchemaCoordinate) -> Option<&CatalogComponent> {
82 self.owners
83 .get(coordinate)
84 .and_then(|id| self.components.get(id))
85 }
86
87 pub fn component_order(&self) -> &[String] {
88 &self.component_order
89 }
90
91 pub fn entries(&self) -> impl Iterator<Item = &CatalogEntry> {
92 self.entries.values()
93 }
94}
95
96#[derive(Debug, Clone)]
99pub struct AdmittedLinkedProgram {
100 pub linked: LinkedProgram,
101 pub resolved_coordinates: Vec<SchemaCoordinate>,
102}
103
104pub fn component_id(members: &[SchemaCoordinate]) -> Result<String, String> {
107 let members = canonical_coordinates(members, "schema catalog component members")?;
108 if members.is_empty() {
109 return Err("schema catalog component requires at least one member".into());
110 }
111 let mut input = format!("[{COMPONENT_EPOCH} [");
112 for (index, coordinate) in members.iter().enumerate() {
113 if index > 0 {
114 input.push(' ');
115 }
116 write!(
117 &mut input,
118 "[:schema :{} \"{}\"]",
119 coordinate.id, coordinate.hash
120 )
121 .expect("writing to String cannot fail");
122 }
123 input.push_str("]]");
124 let digest = Sha256::digest(input.as_bytes());
125 let mut output = String::from(HASH_PREFIX);
126 for byte in digest {
127 write!(&mut output, "{byte:02x}").expect("writing to String cannot fail");
128 }
129 Ok(output)
130}
131
132pub fn admit_catalog(
134 entries: &[CatalogEntry],
135 components: &[CatalogComponent],
136) -> Result<AdmittedCatalog, String> {
137 let mut entry_index = BTreeMap::new();
138 let mut identities = BTreeMap::<String, String>::new();
139 for raw_entry in entries {
140 let entry =
141 CatalogEntry::new(raw_entry.coordinate.clone(), raw_entry.dependencies.clone())?;
142 let identity = entry.coordinate.id.clone();
143 if let Some(existing) = identities.insert(identity, entry.coordinate.hash.clone()) {
144 if existing == entry.coordinate.hash {
145 return Err("schema catalog contains duplicate exact entry".into());
146 }
147 return Err("schema catalog contains conflicting immutable identity".into());
148 }
149 if entry_index
150 .insert(entry.coordinate.clone(), entry)
151 .is_some()
152 {
153 return Err("schema catalog contains duplicate exact entry".into());
154 }
155 }
156
157 for entry in entry_index.values() {
158 for dependency in &entry.dependencies {
159 if !entry_index.contains_key(dependency) {
160 return Err(format!(
161 "schema catalog dependency is not admitted: {}",
162 display_coordinate(dependency)
163 ));
164 }
165 }
166 }
167
168 let graph = entry_graph(&entry_index);
169 let computed_components = strongly_connected_components(&graph);
170
171 let mut component_index = BTreeMap::new();
172 let mut owners = BTreeMap::new();
173 let mut declared_components = Vec::new();
174 for raw_component in components {
175 let component = CatalogComponent::new(
176 raw_component.id.clone(),
177 raw_component.members.clone(),
178 raw_component.dependencies.clone(),
179 )?;
180 let expected_id = component_id(&component.members)?;
181 if component.id != expected_id {
182 return Err(format!(
183 "schema catalog component id mismatch: expected {expected_id}"
184 ));
185 }
186 if component_index
187 .insert(component.id.clone(), component.clone())
188 .is_some()
189 {
190 return Err("schema catalog contains duplicate component id".into());
191 }
192 for member in &component.members {
193 if !entry_index.contains_key(member) {
194 return Err(format!(
195 "schema catalog component member is not admitted: {}",
196 display_coordinate(member)
197 ));
198 }
199 if owners
200 .insert(member.clone(), component.id.clone())
201 .is_some()
202 {
203 return Err(format!(
204 "schema catalog entry belongs to multiple components: {}",
205 display_coordinate(member)
206 ));
207 }
208 }
209 declared_components.push(component.members.clone());
210 }
211
212 if owners.len() != entry_index.len() {
213 let missing = entry_index
214 .keys()
215 .find(|coordinate| !owners.contains_key(*coordinate))
216 .expect("owner count differs only when one entry is missing");
217 return Err(format!(
218 "schema catalog entry has no component evidence: {}",
219 display_coordinate(missing)
220 ));
221 }
222
223 declared_components.sort();
224 if declared_components != computed_components {
225 return Err("schema catalog component evidence does not match dependency graph".into());
226 }
227
228 for component in component_index.values() {
229 let expected = expected_component_dependencies(component, &entry_index, &owners);
230 if component.dependencies != expected {
231 return Err(format!(
232 "schema catalog component dependencies mismatch for {}",
233 component.id
234 ));
235 }
236 }
237
238 let component_graph: BTreeMap<String, BTreeSet<String>> = component_index
239 .iter()
240 .map(|(id, component)| (id.clone(), component.dependencies.iter().cloned().collect()))
241 .collect();
242 let component_order = dependency_first_order(component_graph)?;
243
244 Ok(AdmittedCatalog {
245 entries: entry_index,
246 components: component_index,
247 owners,
248 component_order,
249 })
250}
251
252pub fn admit_linked_program(
255 artifact: &[u8],
256 catalog: &AdmittedCatalog,
257) -> Result<AdmittedLinkedProgram, String> {
258 let linked = decode_linked_program(artifact)?;
259 let mut reachable = BTreeSet::new();
260 let mut pending = linked.schema_links.clone();
261 while let Some(coordinate) = pending.pop() {
262 let Some(entry) = catalog.entry(&coordinate) else {
263 return Err(format!(
264 "linked bytecode schema coordinate is not admitted: {}",
265 display_coordinate(&coordinate)
266 ));
267 };
268 if reachable.insert(coordinate) {
269 pending.extend(entry.dependencies.iter().cloned());
270 }
271 }
272
273 let mut resolved_coordinates = Vec::new();
274 for component_id in &catalog.component_order {
275 let component = catalog
276 .components
277 .get(component_id)
278 .expect("admitted component order references an existing component");
279 for member in &component.members {
280 if reachable.contains(member) {
281 resolved_coordinates.push(member.clone());
282 }
283 }
284 }
285
286 Ok(AdmittedLinkedProgram {
287 linked,
288 resolved_coordinates,
289 })
290}
291
292fn validate_coordinate(coordinate: &SchemaCoordinate) -> Result<(), String> {
293 SchemaCoordinate::new(coordinate.id.clone(), coordinate.hash.clone())
294 .map(|_| ())
295}
296
297fn validate_hash(value: &str, label: &str) -> Result<(), String> {
298 let Some(digest) = value.strip_prefix(HASH_PREFIX) else {
299 return Err(format!("{label} must use sha256"));
300 };
301 if digest.len() == DIGEST_HEX_LENGTH
302 && digest
303 .bytes()
304 .all(|value| value.is_ascii_digit() || (b'a'..=b'f').contains(&value))
305 {
306 Ok(())
307 } else {
308 Err(format!("{label} must be canonical lowercase hex"))
309 }
310}
311
312fn canonical_coordinates(
313 values: &[SchemaCoordinate],
314 label: &str,
315) -> Result<Vec<SchemaCoordinate>, String> {
316 let mut output = values.to_vec();
317 output.sort();
318 let mut identities = BTreeMap::<String, String>::new();
319 for coordinate in &output {
320 validate_coordinate(coordinate)?;
321 let identity = coordinate.id.clone();
322 if let Some(existing) = identities.insert(identity, coordinate.hash.clone()) {
323 if existing == coordinate.hash {
324 return Err(format!("{label} contain a duplicate coordinate"));
325 }
326 return Err(format!("{label} contain conflicting immutable identities"));
327 }
328 }
329 Ok(output)
330}
331
332fn canonical_component_dependencies(values: &[String]) -> Result<Vec<String>, String> {
333 let mut output = values.to_vec();
334 output.sort();
335 for value in &output {
336 validate_hash(value, "schema catalog component dependency")?;
337 }
338 if output.windows(2).any(|pair| pair[0] == pair[1]) {
339 return Err("schema catalog component dependencies contain a duplicate".into());
340 }
341 Ok(output)
342}
343
344fn entry_graph(
345 entries: &BTreeMap<SchemaCoordinate, CatalogEntry>,
346) -> BTreeMap<SchemaCoordinate, BTreeSet<SchemaCoordinate>> {
347 entries
348 .iter()
349 .map(|(coordinate, entry)| {
350 (
351 coordinate.clone(),
352 entry.dependencies.iter().cloned().collect(),
353 )
354 })
355 .collect()
356}
357
358fn strongly_connected_components(
359 graph: &BTreeMap<SchemaCoordinate, BTreeSet<SchemaCoordinate>>,
360) -> Vec<Vec<SchemaCoordinate>> {
361 fn visit_order(
362 node: &SchemaCoordinate,
363 graph: &BTreeMap<SchemaCoordinate, BTreeSet<SchemaCoordinate>>,
364 seen: &mut BTreeSet<SchemaCoordinate>,
365 order: &mut Vec<SchemaCoordinate>,
366 ) {
367 if !seen.insert(node.clone()) {
368 return;
369 }
370 for dependency in graph.get(node).into_iter().flatten() {
371 visit_order(dependency, graph, seen, order);
372 }
373 order.push(node.clone());
374 }
375
376 fn visit_component(
377 node: &SchemaCoordinate,
378 reverse: &BTreeMap<SchemaCoordinate, BTreeSet<SchemaCoordinate>>,
379 seen: &mut BTreeSet<SchemaCoordinate>,
380 members: &mut Vec<SchemaCoordinate>,
381 ) {
382 if !seen.insert(node.clone()) {
383 return;
384 }
385 members.push(node.clone());
386 for dependency in reverse.get(node).into_iter().flatten() {
387 visit_component(dependency, reverse, seen, members);
388 }
389 }
390
391 let mut reverse = graph
392 .keys()
393 .cloned()
394 .map(|coordinate| (coordinate, BTreeSet::new()))
395 .collect::<BTreeMap<_, _>>();
396 for (coordinate, dependencies) in graph {
397 for dependency in dependencies {
398 reverse
399 .get_mut(dependency)
400 .expect("validated dependency exists in graph")
401 .insert(coordinate.clone());
402 }
403 }
404
405 let mut seen = BTreeSet::new();
406 let mut order = Vec::new();
407 for coordinate in graph.keys() {
408 visit_order(coordinate, graph, &mut seen, &mut order);
409 }
410
411 seen.clear();
412 let mut output = Vec::new();
413 while let Some(coordinate) = order.pop() {
414 if seen.contains(&coordinate) {
415 continue;
416 }
417 let mut members = Vec::new();
418 visit_component(&coordinate, &reverse, &mut seen, &mut members);
419 members.sort();
420 output.push(members);
421 }
422 output.sort();
423 output
424}
425
426fn expected_component_dependencies(
427 component: &CatalogComponent,
428 entries: &BTreeMap<SchemaCoordinate, CatalogEntry>,
429 owners: &BTreeMap<SchemaCoordinate, String>,
430) -> Vec<String> {
431 let mut output = BTreeSet::new();
432 for member in &component.members {
433 for dependency in &entries
434 .get(member)
435 .expect("component member is an admitted entry")
436 .dependencies
437 {
438 let owner = owners
439 .get(dependency)
440 .expect("admitted dependency has component evidence");
441 if owner != &component.id {
442 output.insert(owner.clone());
443 }
444 }
445 }
446 output.into_iter().collect()
447}
448
449fn dependency_first_order(
450 mut graph: BTreeMap<String, BTreeSet<String>>,
451) -> Result<Vec<String>, String> {
452 let mut output = Vec::new();
453 while !graph.is_empty() {
454 let ready = graph
455 .iter()
456 .filter_map(|(component, dependencies)| {
457 dependencies.is_empty().then_some(component.clone())
458 })
459 .collect::<Vec<_>>();
460 if ready.is_empty() {
461 return Err("schema catalog component graph contains a cycle".into());
462 }
463 let ready_set = ready.iter().cloned().collect::<BTreeSet<_>>();
464 for component in &ready {
465 graph.remove(component);
466 }
467 for dependencies in graph.values_mut() {
468 dependencies.retain(|dependency| !ready_set.contains(dependency));
469 }
470 output.extend(ready);
471 }
472 Ok(output)
473}
474
475fn display_coordinate(coordinate: &SchemaCoordinate) -> String {
476 format!("[:schema :{} \"{}\"]", coordinate.id, coordinate.hash)
477}
478
479#[cfg(test)]
480mod tests {
481 use super::*;
482 use crate::hbc_schema_links::encode_linked_program;
483 use crate::vm::compile_source;
484
485 fn coordinate(id: &str, digit: char) -> SchemaCoordinate {
486 SchemaCoordinate::new(
487 id,
488 format!("sha256:{}", digit.to_string().repeat(64)),
489 )
490 .unwrap()
491 }
492
493 fn component(members: Vec<SchemaCoordinate>, dependencies: Vec<String>) -> CatalogComponent {
494 CatalogComponent::new(component_id(&members).unwrap(), members, dependencies).unwrap()
495 }
496
497 #[test]
498 fn component_identity_matches_the_portable_catalog_epoch() {
499 let identifier = coordinate("model/id", '1');
500 assert_eq!(
501 component_id(&[identifier]).unwrap(),
502 "sha256:eb2433d563d47c84b3469d37f8786ee00ae0f7080b2505fc839d851615171c32"
503 );
504 }
505
506 #[test]
507 fn linked_program_is_released_with_dependency_first_exact_closure() {
508 let identifier = coordinate("model/id", '1');
509 let profile = coordinate("model/profile", '2');
510 let identifier_component = component(vec![identifier.clone()], vec![]);
511 let profile_component =
512 component(vec![profile.clone()], vec![identifier_component.id.clone()]);
513 let catalog = admit_catalog(
514 &[
515 CatalogEntry::new(identifier.clone(), vec![]).unwrap(),
516 CatalogEntry::new(profile.clone(), vec![identifier.clone()]).unwrap(),
517 ],
518 &[profile_component, identifier_component],
519 )
520 .unwrap();
521
522 let program = compile_source("(+ 19 23)").unwrap();
523 let artifact = encode_linked_program(&program, &[profile.clone()]).unwrap();
524 let admitted = admit_linked_program(&artifact, &catalog).unwrap();
525 assert_eq!(admitted.linked.schema_links, vec![profile.clone()]);
526 assert_eq!(admitted.resolved_coordinates, vec![identifier, profile]);
527 }
528
529 #[test]
530 fn stale_or_missing_exact_links_fail_before_program_release() {
531 let identifier = coordinate("model/id", '1');
532 let catalog = admit_catalog(
533 &[CatalogEntry::new(identifier.clone(), vec![]).unwrap()],
534 &[component(vec![identifier], vec![])],
535 )
536 .unwrap();
537 let stale = coordinate("model/id", '2');
538 let program = compile_source("42").unwrap();
539 let artifact = encode_linked_program(&program, &[stale]).unwrap();
540 assert!(admit_linked_program(&artifact, &catalog)
541 .unwrap_err()
542 .contains("is not admitted"));
543 }
544
545 #[test]
546 fn forged_component_evidence_is_rejected_atomically() {
547 let identifier = coordinate("model/id", '1');
548 let profile = coordinate("model/profile", '2');
549 let forged = component(vec![identifier.clone(), profile.clone()], vec![]);
550 assert_eq!(
551 admit_catalog(
552 &[
553 CatalogEntry::new(identifier.clone(), vec![]).unwrap(),
554 CatalogEntry::new(profile, vec![identifier]).unwrap(),
555 ],
556 &[forged],
557 )
558 .unwrap_err(),
559 "schema catalog component evidence does not match dependency graph"
560 );
561 }
562
563 #[test]
564 fn valid_self_recursion_remains_one_admitted_component() {
565 let node = coordinate("tree/node", '3');
566 let catalog = admit_catalog(
567 &[CatalogEntry::new(node.clone(), vec![node.clone()]).unwrap()],
568 &[component(vec![node.clone()], vec![])],
569 )
570 .unwrap();
571 assert_eq!(catalog.component_order().len(), 1);
572 assert_eq!(catalog.entries().count(), 1);
573 assert_eq!(catalog.component_for(&node).unwrap().members, vec![node]);
574 }
575}