Skip to main content

graphforge_cli/
hub_fixture_artifacts.rs

1//! Rust-owned deterministic artifact generation for the public Hub fixture.
2
3use 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
29/// Stable generator contract. Git commit evidence belongs to the invoking checkout,
30/// rather than caller-controlled artifact metadata.
31pub const GENERATOR_CONTRACT: &str = "graphforge-hub-fixture-generator/1";
32/// Object location base used by the checked-in fixture. Locations are transport,
33/// never identity, so a different base changes only `objects[].locations`.
34pub 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/// One per-module package object advertised by the manifest.
67#[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
116/// Synthetic public research metadata. These are fixture values, not product
117/// behavior; `update_research_metadata` canonicalizes them.
118fn 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
143/// Rebuild the synthetic expanded source package through the public facade and
144/// replace `destination` with it.
145///
146/// The Project is durable, every write identity is fixed, and the export is the
147/// complete expanded package, so repeated rebuilds are byte-identical. The new
148/// tree is built and fully verified beside `destination` and swapped in only
149/// when complete; a failed swap restores the previous tree.
150pub 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    // Stage beside the destination so the final swap is a same-volume rename.
190    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/// Generate canonical object, discovery, and provenance artifacts from the checked-in source.
280///
281/// `location_base` prefixes every digest-addressed object location. It is
282/// transport only: the summary bytes and the manifest's `summary` and `ontology`
283/// descriptors do not depend on it.
284#[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    // The summary is derived from the verified bundle, never entered by hand.
353    let derived = derive_summary(&repository, &immutable_version, &object_path)?;
354    write(destination.join(SUMMARY_PATH), &derived.bytes)?;
355
356    // One component-selective package per advertised module, exported from the
357    // Project reopened from the same checked-in source.
358    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
449/// Regenerate in a private directory and compare every checked-in artifact byte.
450pub 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
468/// Sorted relative paths of every regular file under `root`.
469fn 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
488/// Validate all generator, source, portable, discovery, summary, and metadata bindings.
489fn 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
567/// The summary object must hash to the manifest descriptor, parse through the
568/// public contract, and bind to the manifest it is advertised by.
569fn 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
601/// Every advertised module package must verify and resolve to exactly the
602/// advertised identity, and `fixture.json` must record it.
603fn 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    /// Rewrite a JSON artifact in place.
1022    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    /// Re-bind the ref validator and fixture metadata to the current manifest so a
1030    /// test reaches the check it targets instead of the manifest-digest binding.
1031    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    /// Apply `mutate` to a pristine copy and require `validate` to reject it with
1051    /// a message containing `expected`, and `check` to reject it too.
1052    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        // The manifest itself no longer parses, so no reseal is possible.
1106        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        // Only transport differs: same objects, same digests, different locations.
1178        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        // Every non-manifest artifact is byte-identical across bases.
1214        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        // The mirror manifest is self-consistent but is not the checked-in
1238        // location contract.
1239        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        // Rebuilding over an existing tree replaces it with identical bytes.
1261        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}