1use crate::hub_publish::publication::{
4 ManifestInputs, PACKAGE_MEDIA_TYPE, build_manifest, derive_summary, digest_bytes,
5 export_module_packages, object_descriptor, summary_reference, verify_full,
6};
7use graphforge_api::{
8 ActivationMode, ActivationProfileChangeRequest, DiscoveryOntologyModuleRequest, GraphForge,
9 ModuleAdoptionRequest, OntologyAuthorityExpectation, OntologyDoc, OperationId,
10 PortableSelection, PortableV2ExportRequest, PortableV2ImportRequest, PortableV2Limits,
11 PortableV2Output, PortableV2SelectionProfile, ResearchAccessPolicyMetadata, ResearchCorpusSize,
12 UpdateResearchMetadataRequest, WorkspaceResearchMetadata, WriteContext,
13 repack_verified_expanded_portable_v2, resolve_discovered_ontology_module,
14};
15use graphforge_discovery::{
16 DISCOVERY_FORMAT, DiscoveryLimits, DiscoveryManifest, ExactIdentity, ObjectDescriptor,
17 PORTABLE_V2_FORMAT, PORTABLE_V2_MEDIA_TYPE, PROJECT_SUMMARY_MEDIA_TYPE,
18 PortablePackageReference, ProjectSummary, ProtocolVersion, RefSet, RepositoryIdentity,
19 RepositoryRef, Sha256Digest,
20};
21use serde::Serialize;
22use sha2::{Digest as _, Sha256};
23use std::collections::BTreeMap;
24use std::fmt::Write as _;
25use std::fs;
26use std::path::{Path, PathBuf};
27use uuid::Uuid;
28
29pub const GENERATOR_CONTRACT: &str = "graphforge-hub-fixture-generator/1";
32pub const DEFAULT_LOCATION_BASE: &str = "https://data.graphforge.sh/objects/sha256/";
35const OBJECT_PATH: &str = "objects/openalex-openalex.gfpb";
36const SUMMARY_PATH: &str = "objects/openalex-openalex.summary.json";
37const MODULE_OBJECT_PREFIX: &str = "objects/ontology-module-";
38const MODULE_OBJECT_SUFFIX: &str = ".gfpb";
39const FIXED_FILES: [&str; 5] = [
40 "fixture.json",
41 "manifest.json",
42 "refs.json",
43 OBJECT_PATH,
44 SUMMARY_PATH,
45];
46
47const MODULE_ID: &str = "https://openalex.org/ontology/works";
48const MODULE_VERSION: &str = "2026.01";
49const SOURCE_METADATA_OPERATION: u128 = 0x91f2_1a60_d89e_54e1_a000_0000_0000_0001;
50const SOURCE_ADOPTION_OPERATION: u128 = 0x91f2_1a60_d89e_54e1_a000_0000_0000_0002;
51const SOURCE_PROFILE_OPERATION: u128 = 0x91f2_1a60_d89e_54e1_a000_0000_0000_0003;
52
53#[derive(Debug, Serialize)]
54struct GeneratorIdentity<'a> {
55 contract: &'a str,
56 crate_name: &'a str,
57 source_digest: String,
58}
59
60#[derive(Debug, Serialize)]
61struct SourceIdentity {
62 tree_digest: String,
63 package_digest: String,
64}
65
66#[derive(Debug, PartialEq, Serialize)]
68struct ModuleObjectRecord {
69 ontology_id: String,
70 version: String,
71 content_digest: String,
72 package_digest: String,
73 object_digest: String,
74 object_length: u64,
75 object_path: String,
76}
77
78#[derive(Debug, Serialize)]
79struct FixtureMetadata<'a> {
80 format: &'a str,
81 generator: GeneratorIdentity<'a>,
82 source: SourceIdentity,
83 object_path: &'a str,
84 object_digest: String,
85 object_length: u64,
86 package_digest: String,
87 summary_path: &'a str,
88 summary_digest: String,
89 summary_object_digest: String,
90 summary_object_length: u64,
91 module_objects: Vec<ModuleObjectRecord>,
92 manifest_digest: String,
93}
94
95fn err(error: impl std::fmt::Display) -> String {
96 error.to_string()
97}
98
99fn module_document() -> Result<OntologyDoc, String> {
100 serde_json::from_value(serde_json::json!({
101 "ontology_id": MODULE_ID,
102 "version": MODULE_VERSION,
103 "entity_types": [
104 {"name": "Work", "abstract": false, "parent": null},
105 {"name": "Author", "abstract": false, "parent": null},
106 {"name": "Institution", "abstract": false, "parent": null}
107 ],
108 "relation_types": [{"name": "AUTHORED", "src": "Author", "dst": "Work"}],
109 "properties": [],
110 "constraints": [],
111 "migrations": []
112 }))
113 .map_err(err)
114}
115
116fn fixture_research_metadata() -> WorkspaceResearchMetadata {
119 let mut metadata = WorkspaceResearchMetadata::empty();
120 metadata.title = Some("OpenAlex".into());
121 metadata.authors = vec!["OurResearch".into()];
122 metadata.languages = vec!["en".into()];
123 metadata.subjects = vec!["scholarly-communication".into()];
124 metadata.source_types = vec!["bibliographic-database".into()];
125 metadata.ontologies = vec![MODULE_ID.into()];
126 metadata.license = Some("CC0-1.0".into());
127 metadata.corpus_size = Some(ResearchCorpusSize {
128 node_count: Some(1_000),
129 relationship_count: Some(2_500),
130 source_count: Some(1),
131 artifact_count: Some(0),
132 });
133 metadata.access = ResearchAccessPolicyMetadata {
134 visibility: Some("public".into()),
135 access_policy: Some("open".into()),
136 collaborators: Vec::new(),
137 };
138 metadata.created_at = Some("2026-01-01T00:00:00.000000Z".into());
139 metadata.updated_at = Some("2026-01-01T00:00:00.000000Z".into());
140 metadata
141}
142
143pub fn rebuild_source(destination: &Path) -> Result<(), String> {
151 let limits = PortableV2Limits::default();
152 let parent = destination
153 .parent()
154 .ok_or("fixture source destination has no parent directory")?;
155 fs::create_dir_all(parent).map_err(err)?;
156 let scratch = tempfile::tempdir().map_err(err)?;
157 let project = scratch.path().join("project");
158 fs::create_dir(&project).map_err(err)?;
159 let mut graph = GraphForge::new(project.to_str()).map_err(err)?;
160 graph
161 .update_research_metadata(UpdateResearchMetadataRequest {
162 context: write_context(SOURCE_METADATA_OPERATION),
163 metadata: fixture_research_metadata(),
164 })
165 .map_err(err)?;
166 let candidate = graph
167 .create_ontology_module(module_document()?, Vec::new(), None)
168 .map_err(err)?;
169 graph
170 .adopt_ontology_module(
171 &ModuleAdoptionRequest {
172 authority: authority(&graph, SOURCE_ADOPTION_OPERATION)?,
173 candidate,
174 },
175 None,
176 )
177 .map_err(err)?;
178 let (_, activation) = graph.ontology_activation_profile().map_err(err)?;
179 graph
180 .change_ontology_activation_profile(
181 &ActivationProfileChangeRequest {
182 authority: authority(&graph, SOURCE_PROFILE_OPERATION)?,
183 profile_default: ActivationMode::Advisory,
184 activation,
185 },
186 None,
187 )
188 .map_err(err)?;
189 let staging = tempfile::Builder::new()
191 .prefix(".hub-source-rebuild-")
192 .tempdir_in(parent)
193 .map_err(err)?;
194 let staged = staging.path().join("source");
195 graph
196 .export_portable_v2(
197 &PortableV2ExportRequest {
198 selection: PortableSelection::Current,
199 output_path: staged.clone(),
200 representation: PortableV2Output::Expanded,
201 profile: PortableV2SelectionProfile::Complete,
202 subset: None,
203 limits,
204 },
205 None,
206 |_| {},
207 )
208 .map_err(err)?;
209 verify_full(&staged)?;
210 let previous = staging.path().join("previous");
211 let had_previous = match fs::rename(destination, &previous) {
212 Ok(()) => true,
213 Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
214 Err(error) => return Err(err(error)),
215 };
216 if let Err(error) = fs::rename(&staged, destination) {
217 if had_previous {
218 fs::rename(&previous, destination).map_err(|restore| {
219 format!("{error}; restoring the previous source also failed: {restore}")
220 })?;
221 }
222 return Err(err(error));
223 }
224 Ok(())
225}
226
227fn write_context(operation: u128) -> WriteContext {
228 WriteContext {
229 operation_uuid: OperationId(Uuid::from_u128(operation)),
230 actor_uuid: None,
231 }
232}
233
234fn authority(graph: &GraphForge, operation: u128) -> Result<OntologyAuthorityExpectation, String> {
235 let state = graph.ontology_authority_state().map_err(err)?;
236 Ok(OntologyAuthorityExpectation {
237 context: write_context(operation),
238 expected_project_generation_uuid: state.project_generation_uuid,
239 expected_composition_fingerprint: state.composition_fingerprint,
240 })
241}
242
243fn location(location_base: &str, digest: &str) -> Result<String, String> {
244 let hex = digest
245 .strip_prefix("sha256:")
246 .ok_or("object digest prefix is invalid")?;
247 Ok(format!("{location_base}{hex}"))
248}
249
250fn descriptor(
251 bytes: &[u8],
252 media_type: &str,
253 location_base: &str,
254) -> Result<ObjectDescriptor, String> {
255 let digest = digest_bytes(bytes);
256 Ok(ObjectDescriptor {
257 locations: vec![location(location_base, &digest)?],
258 digest: Sha256Digest(digest),
259 length: u64::try_from(bytes.len()).map_err(|_| "object length overflow")?,
260 media_type: media_type.into(),
261 })
262}
263
264fn module_object_path(content_digest: &str) -> Result<String, String> {
265 let hex = content_digest
266 .strip_prefix("sha256:")
267 .filter(|hex| hex.len() == 64 && hex.bytes().all(|byte| byte.is_ascii_hexdigit()))
268 .ok_or("module content digest is invalid")?;
269 Ok(format!("{MODULE_OBJECT_PREFIX}{hex}{MODULE_OBJECT_SUFFIX}"))
270}
271
272fn repository_identity() -> RepositoryIdentity {
273 RepositoryIdentity {
274 owner: "openalex".into(),
275 repository: "openalex".into(),
276 }
277}
278
279#[expect(
285 clippy::too_many_lines,
286 reason = "one transaction binds portable and discovery artifacts"
287)]
288pub fn generate(source: &Path, destination: &Path, location_base: &str) -> Result<(), String> {
289 let limits = PortableV2Limits::default();
290 let source_report = verify_full(source)?;
291 let scratch = tempfile::tempdir().map_err(err)?;
292 let imported = scratch.path().join("imported");
293 let operation_id = OperationId(Uuid::from_u128(0x91f2_1a60_d89e_54e1_9000_0000_0000_0001));
294 GraphForge::import_portable_v2(
295 &imported,
296 &PortableV2ImportRequest {
297 input: source.to_path_buf(),
298 operation_id,
299 limits,
300 },
301 None,
302 )
303 .map_err(err)?;
304 let imported_project = GraphForge::new(imported.to_str()).map_err(err)?;
305 fs::create_dir_all(destination.join("objects")).map_err(err)?;
306 let object_path = destination.join(OBJECT_PATH);
307 let exported = repack_verified_expanded_portable_v2(
308 &graphforge_api::PortableV2RepackRequest {
309 source: source.to_path_buf(),
310 destination: object_path.clone(),
311 limits,
312 },
313 &std::sync::atomic::AtomicBool::new(false),
314 )
315 .map_err(err)?;
316 let verified = verify_full(&object_path)?;
317 let imported_bundle = scratch.path().join("imported-bundle");
318 let bundle_import = GraphForge::import_portable_v2(
319 &imported_bundle,
320 &PortableV2ImportRequest {
321 input: object_path.clone(),
322 operation_id: OperationId(Uuid::from_u128(0x91f2_1a60_d89e_54e1_9000_0000_0000_0002)),
323 limits,
324 },
325 None,
326 )
327 .map_err(err)?;
328 let reopened_bundle = GraphForge::new(imported_bundle.to_str()).map_err(err)?;
329 if reopened_bundle
330 .committed_generation_identity()
331 .map_err(err)?
332 .generation_uuid
333 != bundle_import.generation_uuid
334 {
335 return Err("generated bundle did not reopen at its imported generation".into());
336 }
337 let exported_package_digest = format!("sha256:{}", hex(&exported.package_digest));
338 let exported_transport_digest = format!("sha256:{}", hex(&exported.transport_digest));
339 if exported_package_digest != verified.package_digest
340 || exported_transport_digest != verified.transport_digest.clone().unwrap_or_default()
341 {
342 return Err("portable export and verification receipts disagree".into());
343 }
344 let bytes = fs::read(&object_path).map_err(err)?;
345 let object_digest = digest_bytes(&bytes);
346 if object_digest != exported_transport_digest {
347 return Err("transport digest does not bind emitted object bytes".into());
348 }
349 let repository = repository_identity();
350 let immutable_version = Sha256Digest(exported_package_digest.clone());
351
352 let derived = derive_summary(&repository, &immutable_version, &object_path)?;
354 write(destination.join(SUMMARY_PATH), &derived.bytes)?;
355
356 let (ontology, modules) =
359 export_module_packages(&imported_project, &derived.summary, |digest| {
360 Ok(destination.join(module_object_path(digest)?))
361 })?;
362 let mut module_objects = Vec::new();
363 let mut module_records = Vec::new();
364 for module in &modules {
365 let module_object = object_descriptor(
366 module.bundle.object_digest.clone(),
367 module.bundle.length,
368 PACKAGE_MEDIA_TYPE,
369 location(location_base, &module.bundle.object_digest)?,
370 );
371 module_records.push(ModuleObjectRecord {
372 ontology_id: module.descriptor.id.clone(),
373 version: module.descriptor.version.clone(),
374 content_digest: module.descriptor.content_digest.0.clone(),
375 package_digest: module.bundle.package_digest.clone(),
376 object_digest: module_object.digest.0.clone(),
377 object_length: module_object.length,
378 object_path: module_object_path(&module.descriptor.content_digest.0)?,
379 });
380 module_objects.push(module_object);
381 }
382
383 let project_object = descriptor(&bytes, PORTABLE_V2_MEDIA_TYPE, location_base)?;
384 let summary_object = descriptor(&derived.bytes, PROJECT_SUMMARY_MEDIA_TYPE, location_base)?;
385 let mut objects = vec![project_object.clone(), summary_object.clone()];
386 objects.extend(module_objects);
387 let manifest = build_manifest(ManifestInputs {
388 repository: repository.clone(),
389 default_ref: "main".into(),
390 resolved_ref: "main".into(),
391 package: PortablePackageReference {
392 format: PORTABLE_V2_FORMAT.into(),
393 package_digest: Sha256Digest(exported_package_digest.clone()),
394 object_digest: project_object.digest.clone(),
395 },
396 summary: Some(summary_reference(&derived, &summary_object)),
397 ontology,
398 lineage: None,
399 objects,
400 })?;
401 let summary = derived.summary;
402 let summary_digest = derived.summary_digest;
403 let manifest_bytes = manifest.to_canonical_json().map_err(err)?;
404 let manifest_digest = manifest.canonical_digest().map_err(err)?.0;
405 let refs = RefSet {
406 format: DISCOVERY_FORMAT.into(),
407 version: ProtocolVersion::CURRENT,
408 repository,
409 default_ref: "main".into(),
410 refs: vec![RepositoryRef {
411 name: "main".into(),
412 target: manifest.immutable_version.clone(),
413 validator: Sha256Digest(manifest_digest.clone()),
414 }],
415 extensions: BTreeMap::new(),
416 };
417 refs.validate_manifest(&manifest).map_err(err)?;
418 manifest.bind_summary(&summary).map_err(err)?;
419 let refs_bytes = refs.to_canonical_json().map_err(err)?;
420 let metadata = FixtureMetadata {
421 format: "graphforge-hub-fixture/1",
422 generator: GeneratorIdentity {
423 contract: GENERATOR_CONTRACT,
424 crate_name: "graphforge-cli",
425 source_digest: generator_source_digest(),
426 },
427 source: SourceIdentity {
428 tree_digest: tree_digest(source)?,
429 package_digest: source_report.package_digest,
430 },
431 object_path: OBJECT_PATH,
432 object_digest,
433 object_length: project_object.length,
434 package_digest: exported_package_digest,
435 summary_path: SUMMARY_PATH,
436 summary_digest: summary_digest.0,
437 summary_object_digest: summary_object.digest.0,
438 summary_object_length: summary_object.length,
439 module_objects: module_records,
440 manifest_digest,
441 };
442 write(destination.join("manifest.json"), &manifest_bytes)?;
443 write(destination.join("refs.json"), &refs_bytes)?;
444 let mut metadata_bytes = serde_json::to_vec(&metadata).map_err(err)?;
445 metadata_bytes.push(b'\n');
446 write(destination.join("fixture.json"), &metadata_bytes)
447}
448
449pub fn check(source: &Path, expected: &Path) -> Result<(), String> {
451 validate(source, expected)?;
452 let scratch = tempfile::tempdir().map_err(err)?;
453 generate(source, scratch.path(), DEFAULT_LOCATION_BASE)?;
454 let regenerated = artifact_files(scratch.path())?;
455 if regenerated != artifact_files(expected)? {
456 return Err("generated Hub fixture artifact set drifted".into());
457 }
458 for relative in regenerated {
459 let actual = fs::read(scratch.path().join(&relative)).map_err(err)?;
460 let expected_bytes = fs::read(expected.join(&relative)).map_err(err)?;
461 if actual != expected_bytes {
462 return Err(format!("generated Hub fixture artifact drift: {relative}"));
463 }
464 }
465 Ok(())
466}
467
468fn artifact_files(root: &Path) -> Result<Vec<String>, String> {
470 let mut files = Vec::new();
471 let mut pending = vec![root.to_path_buf()];
472 while let Some(directory) = pending.pop() {
473 for entry in fs::read_dir(&directory).map_err(err)? {
474 let path = entry.map_err(err)?.path();
475 if fs::symlink_metadata(&path).map_err(err)?.is_dir() {
476 pending.push(path);
477 } else {
478 files.push(canonical_relative_path(
479 path.strip_prefix(root).map_err(err)?,
480 )?);
481 }
482 }
483 }
484 files.sort();
485 Ok(files)
486}
487
488fn validate(source: &Path, artifacts: &Path) -> Result<(), String> {
490 let metadata: serde_json::Value =
491 serde_json::from_slice(&fs::read(artifacts.join("fixture.json")).map_err(err)?)
492 .map_err(err)?;
493 let manifest_bytes = fs::read(artifacts.join("manifest.json")).map_err(err)?;
494 let refs_bytes = fs::read(artifacts.join("refs.json")).map_err(err)?;
495 let manifest =
496 DiscoveryManifest::from_json(&manifest_bytes, DiscoveryLimits::default()).map_err(err)?;
497 let refs = RefSet::from_json(&refs_bytes, DiscoveryLimits::default()).map_err(err)?;
498 refs.validate_manifest(&manifest).map_err(err)?;
499 let module_paths = manifest
500 .ontology
501 .iter()
502 .flat_map(|inventory| &inventory.modules)
503 .map(|module| module_object_path(&module.content_digest.0))
504 .collect::<Result<Vec<_>, _>>()?;
505 validate_expected_tree(artifacts, &module_paths)?;
506 let expected_repository = repository_identity();
507 if manifest.repository != expected_repository
508 || manifest.default_ref != "main"
509 || manifest.resolved_ref != "main"
510 || refs.repository != expected_repository
511 || refs.default_ref != "main"
512 || manifest.immutable_version != manifest.package.package_digest
513 {
514 return Err("fixture repository, ref, or immutable version binding is invalid".into());
515 }
516 let manifest_digest = manifest.canonical_digest().map_err(err)?.0;
517 let reference = refs
518 .refs
519 .iter()
520 .find(|reference| reference.name == "main")
521 .ok_or("fixture main ref is absent")?;
522 if reference.validator.0 != manifest_digest {
523 return Err("fixture validator does not bind canonical manifest bytes".into());
524 }
525 let selected = manifest.package_object().map_err(err)?;
526 if selected.locations != [location(DEFAULT_LOCATION_BASE, &selected.digest.0)?] {
527 return Err("fixture object location is not digest addressed".into());
528 }
529 let object_path = metadata["object_path"]
530 .as_str()
531 .ok_or("fixture object path is absent")?;
532 if object_path != OBJECT_PATH {
533 return Err("fixture object path is invalid".into());
534 }
535 let object = artifacts.join(object_path);
536 let bytes = fs::read(&object).map_err(err)?;
537 let report = verify_full(&object)?;
538 if digest_bytes(&bytes) != selected.digest.0
539 || bytes.len() as u64 != selected.length
540 || report.package_digest != manifest.package.package_digest.0
541 || metadata["format"] != "graphforge-hub-fixture/1"
542 || metadata["object_digest"] != selected.digest.0
543 || metadata["object_length"] != selected.length
544 || metadata["package_digest"] != manifest.package.package_digest.0
545 || metadata["manifest_digest"] != manifest_digest
546 || metadata["generator"]["contract"] != GENERATOR_CONTRACT
547 || metadata["generator"]["crate_name"] != "graphforge-cli"
548 || metadata["generator"]["source_digest"] != generator_source_digest()
549 || metadata["source"]["tree_digest"] != tree_digest(source)?
550 || metadata["source"]["package_digest"] != verify_full(source)?.package_digest
551 {
552 return Err(
553 "fixture portable, discovery, metadata, provenance, or source binding is invalid"
554 .into(),
555 );
556 }
557 validate_summary(artifacts, &manifest, &metadata)?;
558 validate_modules(
559 artifacts,
560 &manifest,
561 &manifest_bytes,
562 &refs_bytes,
563 &metadata,
564 )
565}
566
567fn validate_summary(
570 artifacts: &Path,
571 manifest: &DiscoveryManifest,
572 metadata: &serde_json::Value,
573) -> Result<(), String> {
574 let reference = manifest
575 .summary
576 .as_ref()
577 .ok_or("fixture manifest does not advertise a summary")?;
578 let selected = manifest.summary_object().map_err(err)?;
579 if selected.locations != [location(DEFAULT_LOCATION_BASE, &selected.digest.0)?] {
580 return Err("fixture summary location is not digest addressed".into());
581 }
582 let bytes = fs::read(artifacts.join(SUMMARY_PATH)).map_err(err)?;
583 if digest_bytes(&bytes) != reference.object_digest.0 || bytes.len() as u64 != selected.length {
584 return Err("fixture summary object bytes do not match the manifest descriptor".into());
585 }
586 let summary = ProjectSummary::from_json(&bytes, DiscoveryLimits::default()).map_err(err)?;
587 manifest.bind_summary(&summary).map_err(err)?;
588 if summary.to_canonical_json().map_err(err)? != bytes {
589 return Err("fixture summary object is not canonical".into());
590 }
591 if metadata["summary_path"] != SUMMARY_PATH
592 || metadata["summary_digest"] != reference.summary_digest.0
593 || metadata["summary_object_digest"] != reference.object_digest.0
594 || metadata["summary_object_length"] != selected.length
595 {
596 return Err("fixture summary metadata binding is invalid".into());
597 }
598 Ok(())
599}
600
601fn validate_modules(
604 artifacts: &Path,
605 manifest: &DiscoveryManifest,
606 manifest_bytes: &[u8],
607 refs_bytes: &[u8],
608 metadata: &serde_json::Value,
609) -> Result<(), String> {
610 let mut records = Vec::new();
611 for module in manifest
612 .ontology
613 .iter()
614 .flat_map(|inventory| &inventory.modules)
615 {
616 let package = module
617 .package
618 .as_ref()
619 .ok_or("fixture ontology module has no package")?;
620 let identity = ExactIdentity {
621 id: module.id.clone(),
622 version: module.version.clone(),
623 content_digest: module.content_digest.clone(),
624 };
625 let (_, selected) = manifest.ontology_module_object(&identity).map_err(err)?;
626 if selected.locations != [location(DEFAULT_LOCATION_BASE, &selected.digest.0)?] {
627 return Err("fixture module object location is not digest addressed".into());
628 }
629 let path = module_object_path(&module.content_digest.0)?;
630 let file = artifacts.join(&path);
631 let bytes = fs::read(&file).map_err(err)?;
632 if digest_bytes(&bytes) != selected.digest.0 || bytes.len() as u64 != selected.length {
633 return Err("fixture module object bytes do not match the manifest descriptor".into());
634 }
635 let resolved = resolve_discovered_ontology_module(&DiscoveryOntologyModuleRequest {
636 manifest_json: manifest_bytes,
637 refs_json: refs_bytes,
638 expected_repository: &repository_identity(),
639 module: &identity,
640 package: &file,
641 discovery_limits: DiscoveryLimits::default(),
642 portable_limits: PortableV2Limits::default(),
643 cancelled: None,
644 })
645 .map_err(err)?;
646 if resolved.module.ontology_id != module.id
647 || resolved.module.authored_version != module.version
648 || format!("sha256:{}", resolved.module.canonical_digest) != module.content_digest.0
649 || resolved.package_digest != package.package_digest.0
650 {
651 return Err("fixture module package does not resolve to the advertised module".into());
652 }
653 records.push(ModuleObjectRecord {
654 ontology_id: module.id.clone(),
655 version: module.version.clone(),
656 content_digest: module.content_digest.0.clone(),
657 package_digest: package.package_digest.0.clone(),
658 object_digest: selected.digest.0.clone(),
659 object_length: selected.length,
660 object_path: path,
661 });
662 }
663 if metadata["module_objects"] != serde_json::to_value(&records).map_err(err)? {
664 return Err("fixture module object records do not match the manifest".into());
665 }
666 Ok(())
667}
668
669fn validate_expected_tree(root: &Path, module_paths: &[String]) -> Result<(), String> {
670 let mut actual = Vec::new();
671 let mut pending = vec![root.to_path_buf()];
672 while let Some(directory) = pending.pop() {
673 for entry in fs::read_dir(&directory).map_err(err)? {
674 let entry = entry.map_err(err)?;
675 let path = entry.path();
676 let relative = canonical_relative_path(path.strip_prefix(root).map_err(err)?)?;
677 let metadata = fs::symlink_metadata(&path).map_err(err)?;
678 if metadata.file_type().is_symlink() {
679 return Err(format!(
680 "fixture artifact tree contains a symlink: {relative}"
681 ));
682 }
683 if metadata.is_dir() {
684 actual.push(format!("{relative}/"));
685 pending.push(path);
686 } else if metadata.is_file() {
687 actual.push(relative);
688 } else {
689 return Err("fixture artifact tree contains a non-file entry".into());
690 }
691 }
692 }
693 actual.sort();
694 let mut expected: Vec<String> = FIXED_FILES
695 .iter()
696 .map(|path| (*path).to_owned())
697 .chain(module_paths.iter().cloned())
698 .chain(["objects/".to_owned()])
699 .collect();
700 expected.sort();
701 if actual != expected {
702 return Err("fixture artifact tree does not match the exact generated contract".into());
703 }
704 Ok(())
705}
706
707fn write(path: PathBuf, bytes: &[u8]) -> Result<(), String> {
708 if let Some(parent) = path.parent() {
709 fs::create_dir_all(parent).map_err(err)?;
710 }
711 fs::write(path, bytes).map_err(err)
712}
713
714fn generator_source_digest() -> String {
715 let mut hasher = Sha256::new();
716 hasher.update(b"graphforge-hub-fixture-generator-source/1\0");
717 hasher.update(include_bytes!("hub_fixture_artifacts.rs"));
718 hasher.update(b"\0");
719 hasher.update(include_bytes!("hub_publish/publication.rs"));
720 format!("sha256:{}", hex(&hasher.finalize()))
721}
722
723fn hex(bytes: &[u8]) -> String {
724 bytes.iter().fold(
725 String::with_capacity(bytes.len() * 2),
726 |mut output, byte| {
727 write!(output, "{byte:02x}").expect("writing to String cannot fail");
728 output
729 },
730 )
731}
732
733fn canonical_relative_path(path: &Path) -> Result<String, String> {
734 path.components()
735 .map(|component| match component {
736 std::path::Component::Normal(value) => value
737 .to_str()
738 .map(str::to_owned)
739 .ok_or_else(|| "fixture path component is not UTF-8".to_owned()),
740 _ => Err("fixture path is not a normalized relative path".to_owned()),
741 })
742 .collect::<Result<Vec<_>, _>>()
743 .map(|components| components.join("/"))
744}
745
746fn tree_digest(root: &Path) -> Result<String, String> {
747 fn walk(root: &Path, current: &Path, files: &mut Vec<PathBuf>) -> Result<(), String> {
748 for entry in fs::read_dir(current).map_err(|error| error.to_string())? {
749 let path = entry.map_err(|error| error.to_string())?.path();
750 let metadata = fs::symlink_metadata(&path).map_err(|error| error.to_string())?;
751 if metadata.file_type().is_symlink() {
752 return Err("fixture source may not contain symlinks".into());
753 }
754 if metadata.is_dir() {
755 walk(root, &path, files)?;
756 } else if metadata.is_file() {
757 files.push(
758 path.strip_prefix(root)
759 .map_err(|error| error.to_string())?
760 .to_path_buf(),
761 );
762 } else {
763 return Err("fixture source contains a non-file entry".into());
764 }
765 }
766 Ok(())
767 }
768 let mut files = Vec::new();
769 walk(root, root, &mut files)?;
770 files.sort();
771 let mut hasher = Sha256::new();
772 hasher.update(b"graphforge-hub-fixture-source/1\0");
773 for relative in files {
774 let canonical = canonical_relative_path(&relative)?;
775 let name = canonical.as_bytes();
776 let bytes = fs::read(root.join(&relative)).map_err(|error| error.to_string())?;
777 hasher.update((name.len() as u64).to_be_bytes());
778 hasher.update(name);
779 hasher.update((bytes.len() as u64).to_be_bytes());
780 hasher.update(&bytes);
781 }
782 Ok(format!("sha256:{}", hex(&hasher.finalize())))
783}
784
785#[cfg(test)]
786mod tests {
787 use super::*;
788 use graphforge_api::{PortableV2Mode, PortableVerifyRequest, verify_portable_v2};
789
790 fn root() -> PathBuf {
791 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")
792 }
793
794 fn private_source() -> tempfile::TempDir {
795 fn copy_tree(source: &Path, destination: &Path) {
796 fs::create_dir_all(destination).unwrap();
797 for entry in fs::read_dir(source).unwrap() {
798 let entry = entry.unwrap();
799 let destination = destination.join(entry.file_name());
800 if entry.file_type().unwrap().is_dir() {
801 copy_tree(&entry.path(), &destination);
802 } else {
803 fs::copy(entry.path(), destination).unwrap();
804 }
805 }
806 }
807 let private = tempfile::tempdir().unwrap();
808 copy_tree(
809 &root().join("tests/fixtures/hub/openalex-source"),
810 private.path(),
811 );
812 private
813 }
814
815 #[test]
816 fn relative_paths_use_platform_independent_slash_encoding() {
817 let nested = PathBuf::from("objects").join("sha256").join("fixture.gfpb");
818 assert_eq!(
819 canonical_relative_path(&nested).unwrap(),
820 "objects/sha256/fixture.gfpb"
821 );
822 assert!(canonical_relative_path(Path::new("../outside")).is_err());
823 }
824
825 fn copy_artifacts(source: &Path) -> tempfile::TempDir {
826 let private = tempfile::tempdir().unwrap();
827 copy_tree_for_test(source, private.path());
828 private
829 }
830
831 fn copy_tree_for_test(source: &Path, destination: &Path) {
832 fs::create_dir_all(destination).unwrap();
833 for entry in fs::read_dir(source).unwrap() {
834 let entry = entry.unwrap();
835 let destination = destination.join(entry.file_name());
836 if entry.file_type().unwrap().is_dir() {
837 copy_tree_for_test(&entry.path(), &destination);
838 } else {
839 fs::copy(entry.path(), destination).unwrap();
840 }
841 }
842 }
843
844 #[test]
845 fn checked_in_hub_fixture_is_exact_rust_output() {
846 let source = private_source();
847 let artifacts = copy_artifacts(&root().join("tests/fixtures/hub/generated/v1"));
848 check(source.path(), artifacts.path()).unwrap();
849 }
850
851 #[test]
852 fn check_rejects_tampered_artifact() {
853 let source = private_source();
854 let output = tempfile::tempdir().unwrap();
855 generate(source.path(), output.path(), DEFAULT_LOCATION_BASE).unwrap();
856 fs::write(output.path().join("refs.json"), b"{}\n").unwrap();
857 let error = check(source.path(), output.path()).unwrap_err();
858 assert!(!error.is_empty());
859 }
860
861 #[test]
862 fn generated_artifacts_are_cross_contract_coherent() {
863 let generated = root().join("tests/fixtures/hub/generated/v1");
864 let manifest_bytes = fs::read(generated.join("manifest.json")).unwrap();
865 let refs_bytes = fs::read(generated.join("refs.json")).unwrap();
866 let manifest = DiscoveryManifest::from_json(
867 &manifest_bytes,
868 graphforge_discovery::DiscoveryLimits::default(),
869 )
870 .unwrap();
871 let refs = RefSet::from_json(
872 &refs_bytes,
873 graphforge_discovery::DiscoveryLimits::default(),
874 )
875 .unwrap();
876 refs.validate_manifest(&manifest).unwrap();
877 let object = fs::read(generated.join("objects/openalex-openalex.gfpb")).unwrap();
878 let selected = manifest.package_object().unwrap();
879 assert_eq!(selected.digest.0, digest_bytes(&object));
880 assert_eq!(selected.length, object.len() as u64);
881 assert_eq!(
882 refs.refs[0].validator.0,
883 manifest.canonical_digest().unwrap().0
884 );
885 let private_object = tempfile::NamedTempFile::new().unwrap();
886 fs::write(private_object.path(), &object).unwrap();
887 let verified = verify_portable_v2(
888 &PortableVerifyRequest {
889 input: private_object.path().to_path_buf(),
890 mode: PortableV2Mode::Full,
891 limits: PortableV2Limits::default(),
892 },
893 None,
894 )
895 .unwrap();
896 assert_eq!(verified.package_digest, manifest.package.package_digest.0);
897 let metadata: serde_json::Value =
898 serde_json::from_slice(&fs::read(generated.join("fixture.json")).unwrap()).unwrap();
899 assert_eq!(metadata["object_digest"], selected.digest.0);
900 assert_eq!(metadata["object_length"], selected.length);
901 assert_eq!(
902 metadata["package_digest"],
903 manifest.package.package_digest.0
904 );
905 assert_eq!(metadata["manifest_digest"], refs.refs[0].validator.0);
906 }
907
908 #[test]
909 fn repeated_generation_is_byte_identical() {
910 let source = private_source();
911 let first = tempfile::tempdir().unwrap();
912 let second = tempfile::tempdir().unwrap();
913 generate(source.path(), first.path(), DEFAULT_LOCATION_BASE).unwrap();
914 generate(source.path(), second.path(), DEFAULT_LOCATION_BASE).unwrap();
915 let files = artifact_files(first.path()).unwrap();
916 assert_eq!(files, artifact_files(second.path()).unwrap());
917 for relative in files {
918 assert_eq!(
919 fs::read(first.path().join(&relative)).unwrap(),
920 fs::read(second.path().join(&relative)).unwrap(),
921 "{relative}"
922 );
923 }
924 }
925
926 #[test]
927 fn adversarial_binding_matrix_fails_semantic_validation_and_drift() {
928 let source = private_source();
929 let pristine = root().join("tests/fixtures/hub/generated/v1");
930 let cases: &[(&str, &str, fn(&mut serde_json::Value))] = &[
931 ("version", "manifest.json", |value| {
932 value["version"]["major"] = serde_json::json!(999)
933 }),
934 ("object-digest", "manifest.json", |value| {
935 value["package"]["object_digest"] =
936 serde_json::json!(format!("sha256:{}", "0".repeat(64)))
937 }),
938 ("object-length", "manifest.json", |value| {
939 value["objects"][0]["length"] = serde_json::json!(1)
940 }),
941 ("object-location", "manifest.json", |value| {
942 value["objects"][0]["locations"][0] =
943 serde_json::json!("https://example.com/object")
944 }),
945 ("repository", "manifest.json", |value| {
946 value["repository"]["owner"] = serde_json::json!("other")
947 }),
948 ("default-ref", "manifest.json", |value| {
949 value["default_ref"] = serde_json::json!("other")
950 }),
951 ("immutable-version", "manifest.json", |value| {
952 value["immutable_version"] = serde_json::json!(format!("sha256:{}", "1".repeat(64)))
953 }),
954 ("package-digest", "manifest.json", |value| {
955 value["package"]["package_digest"] =
956 serde_json::json!(format!("sha256:{}", "5".repeat(64)))
957 }),
958 ("refs-target", "refs.json", |value| {
959 value["refs"][0]["target"] = serde_json::json!(format!("sha256:{}", "2".repeat(64)))
960 }),
961 ("refs-validator", "refs.json", |value| {
962 value["refs"][0]["validator"] =
963 serde_json::json!(format!("sha256:{}", "3".repeat(64)))
964 }),
965 ("provenance", "fixture.json", |value| {
966 value["generator"]["source_digest"] =
967 serde_json::json!(format!("sha256:{}", "4".repeat(64)))
968 }),
969 ("object-path", "fixture.json", |value| {
970 value["object_path"] = serde_json::json!("../../outside.gfpb")
971 }),
972 ];
973 for (name, relative, mutate) in cases {
974 let candidate = copy_artifacts(&pristine);
975 let path = candidate.path().join(relative);
976 let mut value: serde_json::Value =
977 serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
978 mutate(&mut value);
979 fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap();
980 assert!(
981 validate(source.path(), candidate.path()).is_err(),
982 "semantic case {name}"
983 );
984 assert!(
985 check(source.path(), candidate.path()).is_err(),
986 "drift case {name}"
987 );
988 }
989 let package = copy_artifacts(&pristine);
990 let path = package.path().join("objects/openalex-openalex.gfpb");
991 let mut bytes = fs::read(&path).unwrap();
992 bytes[1024] ^= 1;
993 fs::write(path, bytes).unwrap();
994 assert!(validate(source.path(), package.path()).is_err());
995 assert!(check(source.path(), package.path()).is_err());
996 let altered_source = private_source();
997 fs::write(altered_source.path().join("bag-info.txt"), b"changed").unwrap();
998 assert!(validate(altered_source.path(), &pristine).is_err());
999 }
1000
1001 #[test]
1002 fn exact_artifact_tree_rejects_extra_files_and_directories() {
1003 let source = private_source();
1004 let pristine = root().join("tests/fixtures/hub/generated/v1");
1005 for relative in ["extra.json", "objects/extra.gfpb", "extra/nested.json"] {
1006 let candidate = copy_artifacts(&pristine);
1007 let path = candidate.path().join(relative);
1008 fs::create_dir_all(path.parent().unwrap()).unwrap();
1009 fs::write(path, b"extra").unwrap();
1010 assert!(
1011 validate(source.path(), candidate.path()).is_err(),
1012 "{relative}"
1013 );
1014 assert!(
1015 check(source.path(), candidate.path()).is_err(),
1016 "{relative}"
1017 );
1018 }
1019 }
1020
1021 fn mutate_json(path: &Path, mutate: impl FnOnce(&mut serde_json::Value)) {
1023 let mut value: serde_json::Value =
1024 serde_json::from_slice(&fs::read(path).unwrap()).unwrap();
1025 mutate(&mut value);
1026 fs::write(path, serde_json::to_vec(&value).unwrap()).unwrap();
1027 }
1028
1029 fn reseal_manifest_digest(artifacts: &Path) {
1032 let manifest = DiscoveryManifest::from_json(
1033 &fs::read(artifacts.join("manifest.json")).unwrap(),
1034 DiscoveryLimits::default(),
1035 )
1036 .unwrap();
1037 let digest = manifest.canonical_digest().unwrap().0;
1038 mutate_json(&artifacts.join("refs.json"), |value| {
1039 value["refs"][0]["validator"] = serde_json::json!(digest);
1040 });
1041 mutate_json(&artifacts.join("fixture.json"), |value| {
1042 value["manifest_digest"] = serde_json::json!(digest);
1043 });
1044 }
1045
1046 fn other_digest() -> serde_json::Value {
1047 serde_json::json!(format!("sha256:{}", "7".repeat(64)))
1048 }
1049
1050 fn assert_rejected(name: &str, expected: &str, mutate: impl FnOnce(&Path)) {
1053 let source = private_source();
1054 let candidate = copy_artifacts(&root().join("tests/fixtures/hub/generated/v1"));
1055 mutate(candidate.path());
1056 let error = validate(source.path(), candidate.path()).unwrap_err();
1057 assert!(error.contains(expected), "{name}: {error}");
1058 assert!(check(source.path(), candidate.path()).is_err(), "{name}");
1059 }
1060
1061 fn mutate_manifest(dir: &Path, mutate: impl FnOnce(&mut serde_json::Value)) {
1062 mutate_json(&dir.join("manifest.json"), mutate);
1063 }
1064
1065 #[test]
1066 fn manifest_summary_and_ontology_descriptors_are_each_enforced() {
1067 assert_rejected("summary-digest", "disagrees with manifest", |dir| {
1068 mutate_manifest(dir, |value| {
1069 value["summary"]["summary_digest"] = other_digest();
1070 });
1071 reseal_manifest_digest(dir);
1072 });
1073 assert_rejected("summary-removed", "does not advertise a summary", |dir| {
1074 mutate_manifest(dir, |value| {
1075 value.as_object_mut().unwrap().remove("summary");
1076 });
1077 reseal_manifest_digest(dir);
1078 });
1079 assert_rejected(
1080 "ontology-composition-digest",
1081 "disagrees with manifest",
1082 |dir| {
1083 mutate_manifest(dir, |value| {
1084 value["ontology"]["composition_digest"] = other_digest();
1085 });
1086 reseal_manifest_digest(dir);
1087 },
1088 );
1089 assert_rejected("ontology-removed", "exact generated contract", |dir| {
1090 mutate_manifest(dir, |value| {
1091 value.as_object_mut().unwrap().remove("ontology");
1092 });
1093 reseal_manifest_digest(dir);
1094 });
1095 assert_rejected(
1096 "module-package-digest",
1097 "reference mismatch: PackageDigest",
1098 |dir| {
1099 mutate_manifest(dir, |value| {
1100 value["ontology"]["modules"][0]["package"]["package_digest"] = other_digest();
1101 });
1102 reseal_manifest_digest(dir);
1103 },
1104 );
1105 assert_rejected(
1107 "module-package-is-project-package",
1108 "module package object is incompatible",
1109 |dir| {
1110 mutate_manifest(dir, |value| {
1111 let project = value["package"]["object_digest"].clone();
1112 value["ontology"]["modules"][0]["package"]["object_digest"] = project;
1113 });
1114 },
1115 );
1116 }
1117
1118 #[test]
1119 fn summary_and_module_objects_and_records_are_each_enforced() {
1120 let pristine = root().join("tests/fixtures/hub/generated/v1");
1121 let module_object = fs::read_dir(pristine.join("objects"))
1122 .unwrap()
1123 .map(|entry| entry.unwrap().file_name().into_string().unwrap())
1124 .find(|name| name.starts_with("ontology-module-"))
1125 .unwrap();
1126 assert_rejected("summary-object-bytes", "summary object bytes", |dir| {
1127 let path = dir.join(SUMMARY_PATH);
1128 let mut bytes = fs::read(&path).unwrap();
1129 bytes[10] ^= 1;
1130 fs::write(path, bytes).unwrap();
1131 });
1132 assert_rejected("module-object-bytes", "module object bytes", |dir| {
1133 let path = dir.join("objects").join(&module_object);
1134 let mut bytes = fs::read(&path).unwrap();
1135 bytes[1024] ^= 1;
1136 fs::write(path, bytes).unwrap();
1137 });
1138 assert_rejected("fixture-summary-digest", "summary metadata", |dir| {
1139 mutate_json(&dir.join("fixture.json"), |value| {
1140 value["summary_digest"] = other_digest();
1141 });
1142 });
1143 assert_rejected("fixture-module-record", "module object records", |dir| {
1144 mutate_json(&dir.join("fixture.json"), |value| {
1145 value["module_objects"][0]["package_digest"] = other_digest();
1146 });
1147 });
1148 }
1149
1150 #[test]
1151 fn summary_and_descriptors_do_not_depend_on_the_object_location_base() {
1152 let source = private_source();
1153 let first = tempfile::tempdir().unwrap();
1154 let second = tempfile::tempdir().unwrap();
1155 generate(source.path(), first.path(), DEFAULT_LOCATION_BASE).unwrap();
1156 let mirror = "https://mirror.example.org/content/";
1157 generate(source.path(), second.path(), mirror).unwrap();
1158
1159 assert_eq!(
1160 fs::read(first.path().join(SUMMARY_PATH)).unwrap(),
1161 fs::read(second.path().join(SUMMARY_PATH)).unwrap()
1162 );
1163 let manifest = |dir: &Path| -> serde_json::Value {
1164 serde_json::from_slice(&fs::read(dir.join("manifest.json")).unwrap()).unwrap()
1165 };
1166 let (first_manifest, second_manifest) = (manifest(first.path()), manifest(second.path()));
1167 for field in [
1168 "summary",
1169 "ontology",
1170 "package",
1171 "immutable_version",
1172 "repository",
1173 ] {
1174 assert_eq!(first_manifest[field], second_manifest[field], "{field}");
1175 assert!(!first_manifest[field].is_null(), "{field}");
1176 }
1177 let objects =
1179 |manifest: &serde_json::Value| -> Vec<(serde_json::Value, serde_json::Value)> {
1180 manifest["objects"]
1181 .as_array()
1182 .unwrap()
1183 .iter()
1184 .map(|object| (object["digest"].clone(), object["locations"].clone()))
1185 .collect()
1186 };
1187 let (default_objects, mirror_objects) =
1188 (objects(&first_manifest), objects(&second_manifest));
1189 assert_eq!(default_objects.len(), 3);
1190 for ((first_digest, first_locations), (second_digest, second_locations)) in
1191 default_objects.iter().zip(&mirror_objects)
1192 {
1193 assert_eq!(first_digest, second_digest);
1194 assert_ne!(first_locations, second_locations);
1195 assert!(
1196 first_locations[0]
1197 .as_str()
1198 .unwrap()
1199 .starts_with(DEFAULT_LOCATION_BASE)
1200 );
1201 assert_eq!(
1202 second_locations[0].as_str().unwrap(),
1203 format!(
1204 "{mirror}{}",
1205 first_digest
1206 .as_str()
1207 .unwrap()
1208 .strip_prefix("sha256:")
1209 .unwrap()
1210 )
1211 );
1212 }
1213 for relative in artifact_files(first.path()).unwrap() {
1215 if relative == "manifest.json" || relative == "refs.json" || relative == "fixture.json"
1216 {
1217 continue;
1218 }
1219 assert_eq!(
1220 fs::read(first.path().join(&relative)).unwrap(),
1221 fs::read(second.path().join(&relative)).unwrap(),
1222 "{relative}"
1223 );
1224 }
1225 let metadata = |dir: &Path| -> serde_json::Value {
1226 serde_json::from_slice(&fs::read(dir.join("fixture.json")).unwrap()).unwrap()
1227 };
1228 let (first_metadata, second_metadata) = (metadata(first.path()), metadata(second.path()));
1229 for field in [
1230 "summary_digest",
1231 "summary_object_digest",
1232 "module_objects",
1233 "package_digest",
1234 ] {
1235 assert_eq!(first_metadata[field], second_metadata[field], "{field}");
1236 }
1237 assert!(validate(source.path(), second.path()).is_err());
1240 }
1241
1242 fn tree_bytes(root: &Path) -> BTreeMap<String, Vec<u8>> {
1243 artifact_files(root)
1244 .unwrap()
1245 .into_iter()
1246 .map(|relative| {
1247 let bytes = fs::read(root.join(&relative)).unwrap();
1248 (relative, bytes)
1249 })
1250 .collect()
1251 }
1252
1253 #[test]
1254 fn rebuilt_source_is_deterministic_and_matches_the_checked_in_tree() {
1255 let workspace = tempfile::tempdir().unwrap();
1256 let first = workspace.path().join("first");
1257 let second = workspace.path().join("second");
1258 rebuild_source(&first).unwrap();
1259 rebuild_source(&second).unwrap();
1260 rebuild_source(&first).unwrap();
1262 let rebuilt = tree_bytes(&first);
1263 assert_eq!(rebuilt, tree_bytes(&second));
1264 assert_eq!(
1265 rebuilt,
1266 tree_bytes(&root().join("tests/fixtures/hub/openalex-source")),
1267 "checked-in source is not the Rust rebuild; run the generator with --rebuild-source"
1268 );
1269 assert_eq!(
1270 fs::read_dir(workspace.path()).unwrap().count(),
1271 2,
1272 "rebuild left staging directories behind"
1273 );
1274 verify_full(&first).unwrap();
1275 }
1276}