Skip to main content

animsmith_core/
dependency_closure.rs

1//! Bounded identities for a source file and its declared resource closure.
2//!
3//! Core owns the immutable value contract and canonical digest. Format crates
4//! own rooted filesystem access and provide only safe logical keys and byte
5//! identities to [`DependencyClosureBuilderV1`].
6
7use crate::bounded_deserialize::{CappedSequence, deserialize_capped_sequence};
8use crate::{
9    InputIdentity, SourceFactSetV1, SourceFormatV1, SourceRelativeLocatorV1, SourceResourceKindV1,
10    SourceResourceLocatorV1, SourceResourceReferenceV1, SourceSetCoverageStateV1,
11    SourceSetCoverageV1,
12};
13use serde::de::Error as _;
14use serde::{Deserialize, Deserializer, Serialize};
15use std::collections::{BTreeMap, BTreeSet};
16
17/// Semantic identity of the dependency-closure V1 value contract.
18pub const DEPENDENCY_CLOSURE_V1_ID: &str = "urn:animsmith:dependency-closure:1";
19/// Semantic identity of the immutable dependency-closure V1 budget.
20pub const DEPENDENCY_CLOSURE_BUDGET_V1_ID: &str = "urn:animsmith:dependency-closure-budget:1";
21/// Maximum source-resource declarations inspected by one closure capture.
22pub const DEPENDENCY_CLOSURE_V1_MAX_REFERENCES: usize = 4_096;
23/// Maximum distinct external logical keys captured by one closure.
24pub const DEPENDENCY_CLOSURE_V1_MAX_EXTERNAL_RESOURCES: usize = 1_024;
25/// Maximum UTF-8 bytes in a source locator or normalized logical key.
26pub const DEPENDENCY_CLOSURE_V1_MAX_KEY_BYTES: usize = 4_096;
27/// Maximum path components in one logical resource key.
28pub const DEPENDENCY_CLOSURE_V1_MAX_PATH_COMPONENTS: usize = 128;
29/// Maximum aggregate source-locator bytes inspected during normalization.
30pub const DEPENDENCY_CLOSURE_V1_MAX_NORMALIZATION_BYTES: usize = 8 * 1024 * 1024;
31/// Maximum bytes read and hashed for one external resource.
32pub const DEPENDENCY_CLOSURE_V1_MAX_RESOURCE_BYTES: u64 = 64 * 1024 * 1024;
33/// Maximum aggregate distinct external bytes read and hashed.
34pub const DEPENDENCY_CLOSURE_V1_MAX_TOTAL_RESOURCE_BYTES: u64 = 256 * 1024 * 1024;
35/// Maximum alias/deduplication probes, one per inspected declaration.
36pub const DEPENDENCY_CLOSURE_V1_MAX_DEDUP_PROBES: usize = 4_096;
37
38/// Immutable numeric limits that define dependency-closure V1.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
40pub struct ResourceClosureBudgetV1 {
41    schema: &'static str,
42    max_references: usize,
43    max_external_resources: usize,
44    max_key_bytes: usize,
45    max_path_components: usize,
46    max_normalization_bytes: usize,
47    max_resource_bytes: u64,
48    max_total_resource_bytes: u64,
49    max_dedup_probes: usize,
50}
51
52impl<'de> Deserialize<'de> for ResourceClosureBudgetV1 {
53    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
54    where
55        D: Deserializer<'de>,
56    {
57        #[derive(Deserialize)]
58        #[serde(deny_unknown_fields)]
59        struct WireBudget {
60            schema: String,
61            max_references: usize,
62            max_external_resources: usize,
63            max_key_bytes: usize,
64            max_path_components: usize,
65            max_normalization_bytes: usize,
66            max_resource_bytes: u64,
67            max_total_resource_bytes: u64,
68            max_dedup_probes: usize,
69        }
70        let wire = WireBudget::deserialize(deserializer)?;
71        let expected = Self::VALUE;
72        if wire.schema != expected.schema
73            || wire.max_references != expected.max_references
74            || wire.max_external_resources != expected.max_external_resources
75            || wire.max_key_bytes != expected.max_key_bytes
76            || wire.max_path_components != expected.max_path_components
77            || wire.max_normalization_bytes != expected.max_normalization_bytes
78            || wire.max_resource_bytes != expected.max_resource_bytes
79            || wire.max_total_resource_bytes != expected.max_total_resource_bytes
80            || wire.max_dedup_probes != expected.max_dedup_probes
81        {
82            return Err(D::Error::custom(
83                "dependency-closure budget must equal immutable V1",
84            ));
85        }
86        Ok(expected)
87    }
88}
89
90impl ResourceClosureBudgetV1 {
91    /// The only budget used by dependency-closure V1.
92    pub const VALUE: Self = Self {
93        schema: DEPENDENCY_CLOSURE_BUDGET_V1_ID,
94        max_references: DEPENDENCY_CLOSURE_V1_MAX_REFERENCES,
95        max_external_resources: DEPENDENCY_CLOSURE_V1_MAX_EXTERNAL_RESOURCES,
96        max_key_bytes: DEPENDENCY_CLOSURE_V1_MAX_KEY_BYTES,
97        max_path_components: DEPENDENCY_CLOSURE_V1_MAX_PATH_COMPONENTS,
98        max_normalization_bytes: DEPENDENCY_CLOSURE_V1_MAX_NORMALIZATION_BYTES,
99        max_resource_bytes: DEPENDENCY_CLOSURE_V1_MAX_RESOURCE_BYTES,
100        max_total_resource_bytes: DEPENDENCY_CLOSURE_V1_MAX_TOTAL_RESOURCE_BYTES,
101        max_dedup_probes: DEPENDENCY_CLOSURE_V1_MAX_DEDUP_PROBES,
102    };
103
104    /// Semantic identity of these fixed limits.
105    pub const fn contract_id(self) -> &'static str {
106        DEPENDENCY_CLOSURE_BUDGET_V1_ID
107    }
108
109    /// Maximum declaration rows.
110    pub const fn max_references(self) -> usize {
111        self.max_references
112    }
113
114    /// Maximum distinct external logical keys.
115    pub const fn max_external_resources(self) -> usize {
116        self.max_external_resources
117    }
118
119    /// Maximum bytes in one locator or normalized key.
120    pub const fn max_key_bytes(self) -> usize {
121        self.max_key_bytes
122    }
123
124    /// Maximum components in one locator.
125    pub const fn max_path_components(self) -> usize {
126        self.max_path_components
127    }
128
129    /// Maximum aggregate bytes inspected by normalization.
130    pub const fn max_normalization_bytes(self) -> usize {
131        self.max_normalization_bytes
132    }
133
134    /// Maximum bytes captured for one external resource.
135    pub const fn max_resource_bytes(self) -> u64 {
136        self.max_resource_bytes
137    }
138
139    /// Maximum aggregate distinct external bytes captured.
140    pub const fn max_total_resource_bytes(self) -> u64 {
141        self.max_total_resource_bytes
142    }
143
144    /// Maximum alias/deduplication probes.
145    pub const fn max_dedup_probes(self) -> usize {
146        self.max_dedup_probes
147    }
148}
149
150/// Format-neutral consumer purpose derived from a source resource kind.
151#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
152#[serde(rename_all = "snake_case")]
153pub enum DependencyResourcePurposeV1 {
154    /// The loader requires these bytes to construct its normalized document.
155    LoaderEssential,
156    /// The declaration is retained, but absence need not fail document loading.
157    Nonessential,
158    /// The declaration is relevant only to a later target/importer workflow.
159    TargetOnly,
160}
161
162impl DependencyResourcePurposeV1 {
163    const fn from_kind(kind: SourceResourceKindV1) -> Self {
164        match kind {
165            SourceResourceKindV1::Buffer => Self::LoaderEssential,
166            SourceResourceKindV1::Image | SourceResourceKindV1::Texture => Self::Nonessential,
167            SourceResourceKindV1::Video | SourceResourceKindV1::Cache => Self::TargetOnly,
168        }
169    }
170}
171
172/// Format-specific lexical interpretation for a retained relative locator.
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub enum ResourceKeySyntaxV1 {
175    /// A glTF URI: valid percent escapes are decoded before canonicalization.
176    GltfUri,
177    /// A parser-projected relative path: percent signs remain literal bytes.
178    ParserRelativePath,
179}
180
181/// Safe, normalized, source-relative dependency key.
182#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
183#[serde(transparent)]
184pub struct DependencyResourceKeyV1(String);
185
186impl DependencyResourceKeyV1 {
187    /// Normalize one already-redacted relative locator without host I/O.
188    ///
189    /// # Errors
190    ///
191    /// Returns [`DependencyClosureError`] for invalid percent encoding,
192    /// unsafe path syntax, too many components, or an oversized normalized key.
193    pub fn from_relative(
194        locator: &SourceRelativeLocatorV1,
195        syntax: ResourceKeySyntaxV1,
196    ) -> Result<Self, DependencyClosureError> {
197        Self::from_source_str(locator.as_str(), syntax)
198    }
199
200    /// Normalize one raw source spelling while enforcing the same fail-closed
201    /// contract as [`Self::from_relative`].
202    ///
203    /// Format loaders normally call [`Self::from_relative`] after raw-source
204    /// classification. This constructor is also useful for bounded preflight
205    /// tests and custom loaders that do not retain a raw-facts row first.
206    ///
207    /// # Errors
208    ///
209    /// Returns [`DependencyClosureError`] for an unsafe or oversized key.
210    pub fn from_source_str(
211        raw: &str,
212        syntax: ResourceKeySyntaxV1,
213    ) -> Result<Self, DependencyClosureError> {
214        if raw.len() > DEPENDENCY_CLOSURE_V1_MAX_KEY_BYTES {
215            return Err(DependencyClosureError::ResourceKeyTooLong {
216                bytes: raw.len(),
217                limit: DEPENDENCY_CLOSURE_V1_MAX_KEY_BYTES,
218            });
219        }
220        if raw.contains('\\') || raw.chars().any(char::is_control) {
221            return Err(DependencyClosureError::InvalidResourceKey);
222        }
223        let normalized = match syntax {
224            ResourceKeySyntaxV1::GltfUri => decode_percent_utf8(raw)?,
225            ResourceKeySyntaxV1::ParserRelativePath => raw.to_owned(),
226        };
227        validate_normalized_key(&normalized)?;
228        Ok(Self(normalized))
229    }
230
231    /// Safe normalized spelling used in digest input and rooted lookup.
232    pub fn as_str(&self) -> &str {
233        &self.0
234    }
235
236    /// Number of source path components, without allocation.
237    pub fn source_component_count(locator: &SourceRelativeLocatorV1) -> usize {
238        locator.as_str().split('/').count()
239    }
240}
241
242impl<'de> Deserialize<'de> for DependencyResourceKeyV1 {
243    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
244    where
245        D: Deserializer<'de>,
246    {
247        Self::from_source_str(
248            &String::deserialize(deserializer)?,
249            ResourceKeySyntaxV1::ParserRelativePath,
250        )
251        .map_err(D::Error::custom)
252    }
253}
254
255fn decode_percent_utf8(raw: &str) -> Result<String, DependencyClosureError> {
256    let bytes = raw.as_bytes();
257    let mut decoded = Vec::with_capacity(bytes.len());
258    let mut index = 0usize;
259    while index < bytes.len() {
260        if bytes[index] != b'%' {
261            decoded.push(bytes[index]);
262            index += 1;
263            continue;
264        }
265        let high = bytes
266            .get(index + 1)
267            .copied()
268            .and_then(hex)
269            .ok_or(DependencyClosureError::InvalidResourceKey)?;
270        let low = bytes
271            .get(index + 2)
272            .copied()
273            .and_then(hex)
274            .ok_or(DependencyClosureError::InvalidResourceKey)?;
275        let value = (high << 4) | low;
276        if matches!(value, b'/' | b'\\' | 0) {
277            return Err(DependencyClosureError::InvalidResourceKey);
278        }
279        decoded.push(value);
280        index += 3;
281    }
282    String::from_utf8(decoded).map_err(|_| DependencyClosureError::InvalidResourceKey)
283}
284
285fn hex(value: u8) -> Option<u8> {
286    match value {
287        b'0'..=b'9' => Some(value - b'0'),
288        b'a'..=b'f' => Some(value - b'a' + 10),
289        b'A'..=b'F' => Some(value - b'A' + 10),
290        _ => None,
291    }
292}
293
294fn validate_normalized_key(value: &str) -> Result<(), DependencyClosureError> {
295    if value.is_empty()
296        || value.len() > DEPENDENCY_CLOSURE_V1_MAX_KEY_BYTES
297        || value.starts_with('/')
298        || value.contains('\\')
299        || value.contains([':', '?', '#'])
300        || value.chars().any(char::is_control)
301        || has_uri_scheme(value)
302    {
303        return Err(DependencyClosureError::InvalidResourceKey);
304    }
305    let mut components = 0usize;
306    for component in value.split('/') {
307        components = components.saturating_add(1);
308        if component.is_empty() || matches!(component, "." | "..") {
309            return Err(DependencyClosureError::InvalidResourceKey);
310        }
311    }
312    if components > DEPENDENCY_CLOSURE_V1_MAX_PATH_COMPONENTS {
313        return Err(DependencyClosureError::TooManyPathComponents {
314            components,
315            limit: DEPENDENCY_CLOSURE_V1_MAX_PATH_COMPONENTS,
316        });
317    }
318    Ok(())
319}
320
321fn has_uri_scheme(value: &str) -> bool {
322    let Some((scheme, _)) = value.split_once(':') else {
323        return false;
324    };
325    !scheme.is_empty()
326        && scheme.as_bytes()[0].is_ascii_alphabetic()
327        && scheme
328            .bytes()
329            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'-' | b'.'))
330}
331
332/// Why a source-controlled locator was refused without opening it.
333#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
334#[serde(rename_all = "snake_case")]
335pub enum DependencyResourceRefusalReasonV1 {
336    /// Absolute or drive-qualified locator.
337    Absolute,
338    /// Lexical traversal or out-of-root locator.
339    Escaping,
340    /// Remote URI scheme.
341    Remote,
342    /// Malformed source spelling.
343    Malformed,
344    /// Source spelling exceeded the locator budget.
345    Oversized,
346    /// A host path component or final target was a symbolic link.
347    Symlink,
348}
349
350/// Why a safe declaration could not be assigned a content identity.
351#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
352#[serde(rename_all = "snake_case")]
353pub enum DependencyResourceUnavailableReasonV1 {
354    /// The byte-loading caller supplied no trusted resource root.
355    ResourceRootUnavailable,
356    /// The accepted relative resource does not exist.
357    Missing,
358    /// The accepted relative resource could not be opened or read.
359    Unreadable,
360    /// A closure capture budget stopped this resource.
361    ResourceBudgetExceeded,
362}
363
364/// One declaration's closure mapping.
365#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
366#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
367pub enum DependencyReferenceTargetV1 {
368    /// Bytes are carried by the exact primary input.
369    Primary,
370    /// Bytes came from one captured normalized external key.
371    External {
372        /// Key of the distinct external-resource row.
373        key: DependencyResourceKeyV1,
374    },
375    /// Locator was rejected before any open attempt.
376    Refused {
377        /// Safe normalized key for a relative locator refused as a symlink.
378        #[serde(skip_serializing_if = "Option::is_none")]
379        key: Option<DependencyResourceKeyV1>,
380        /// Stable refusal class; unsafe spelling is never retained.
381        reason: DependencyResourceRefusalReasonV1,
382    },
383    /// A safe declaration had no available captured identity.
384    Unavailable {
385        /// Safe normalized key when the declaration supplied one.
386        #[serde(skip_serializing_if = "Option::is_none")]
387        key: Option<DependencyResourceKeyV1>,
388        /// Stable unavailable class; host error text is never retained.
389        reason: DependencyResourceUnavailableReasonV1,
390    },
391}
392
393/// One source-order declaration-to-content mapping.
394#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
395pub struct DependencyClosureReferenceV1 {
396    source_order_index: usize,
397    kind: SourceResourceKindV1,
398    purpose: DependencyResourcePurposeV1,
399    source_index: u64,
400    target: DependencyReferenceTargetV1,
401}
402
403impl<'de> Deserialize<'de> for DependencyClosureReferenceV1 {
404    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
405    where
406        D: Deserializer<'de>,
407    {
408        #[derive(Debug, Clone, Copy, Deserialize)]
409        #[serde(rename_all = "snake_case")]
410        enum WireResourceKind {
411            Buffer,
412            Image,
413            Texture,
414            Video,
415            Cache,
416        }
417        impl From<WireResourceKind> for SourceResourceKindV1 {
418            fn from(value: WireResourceKind) -> Self {
419                match value {
420                    WireResourceKind::Buffer => Self::Buffer,
421                    WireResourceKind::Image => Self::Image,
422                    WireResourceKind::Texture => Self::Texture,
423                    WireResourceKind::Video => Self::Video,
424                    WireResourceKind::Cache => Self::Cache,
425                }
426            }
427        }
428        #[derive(Deserialize)]
429        #[serde(deny_unknown_fields)]
430        struct WireReference {
431            source_order_index: usize,
432            kind: WireResourceKind,
433            purpose: DependencyResourcePurposeV1,
434            source_index: u64,
435            target: DependencyReferenceTargetV1,
436        }
437        let wire = WireReference::deserialize(deserializer)?;
438        let kind = SourceResourceKindV1::from(wire.kind);
439        let reference = Self::new(
440            wire.source_order_index,
441            kind,
442            wire.source_index,
443            wire.target,
444        );
445        if reference.purpose != wire.purpose {
446            return Err(D::Error::custom(
447                "dependency reference purpose must be derived from kind",
448            ));
449        }
450        Ok(reference)
451    }
452}
453
454impl DependencyClosureReferenceV1 {
455    fn new(
456        source_order_index: usize,
457        kind: SourceResourceKindV1,
458        source_index: u64,
459        target: DependencyReferenceTargetV1,
460    ) -> Self {
461        Self {
462            source_order_index,
463            kind,
464            purpose: DependencyResourcePurposeV1::from_kind(kind),
465            source_index,
466            target,
467        }
468    }
469
470    /// Deterministic source declaration order.
471    pub const fn source_order_index(&self) -> usize {
472        self.source_order_index
473    }
474
475    /// Source declaration kind.
476    pub const fn kind(&self) -> SourceResourceKindV1 {
477        self.kind
478    }
479
480    /// Format-neutral consumer purpose authoritatively derived from [`Self::kind`].
481    pub const fn purpose(&self) -> DependencyResourcePurposeV1 {
482        self.purpose
483    }
484
485    /// Stable source/parser declaration index.
486    pub const fn source_index(&self) -> u64 {
487        self.source_index
488    }
489
490    /// Captured/refused/unavailable mapping outcome.
491    pub const fn target(&self) -> &DependencyReferenceTargetV1 {
492        &self.target
493    }
494}
495
496/// One distinct external logical key and the exact bytes captured once.
497#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
498#[serde(deny_unknown_fields)]
499pub struct ExternalResourceIdentityV1 {
500    key: DependencyResourceKeyV1,
501    identity: InputIdentity,
502}
503
504impl ExternalResourceIdentityV1 {
505    /// Safe normalized source-relative key.
506    pub const fn key(&self) -> &DependencyResourceKeyV1 {
507        &self.key
508    }
509
510    /// SHA-256 and byte count of the exact captured bytes.
511    pub const fn identity(&self) -> &InputIdentity {
512        &self.identity
513    }
514}
515
516/// Why the closure as a whole is not complete.
517#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
518#[serde(rename_all = "snake_case")]
519pub enum DependencyClosureCoverageReasonV1 {
520    /// Raw declaration projection retained only a positive prefix.
521    SourceDeclarationsPartial,
522    /// Raw declaration projection was unavailable.
523    SourceDeclarationsUnavailable,
524    /// The legacy/custom completion path did not capture a dependency closure.
525    CaptureUnavailable,
526    /// At least one declaration was refused.
527    RefusedResource,
528    /// At least one safe declaration lacked an identity.
529    UnavailableResource,
530    /// A closure budget stopped capture at N+1.
531    ResourceBudgetExceeded,
532    /// A known format/parser domain can carry unmodelled resource declarations.
533    UnmodeledResourceDomain,
534}
535
536/// Complete, partial, or unavailable closure coverage.
537#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
538#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
539pub enum DependencyClosureCoverageV1 {
540    /// Every retained source declaration maps to exact content and the source domain is complete.
541    Complete,
542    /// Retained mappings are positive evidence but do not establish a full closure.
543    Partial {
544        /// Sorted unique reasons completeness could not be established.
545        reasons: Vec<DependencyClosureCoverageReasonV1>,
546    },
547    /// The source declaration domain itself could not be projected.
548    Unavailable {
549        /// Sorted unique reasons no closure can be established.
550        reasons: Vec<DependencyClosureCoverageReasonV1>,
551    },
552}
553
554const DEPENDENCY_CLOSURE_COVERAGE_REASON_VARIANTS: usize = 7;
555
556#[derive(Deserialize)]
557#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
558enum DependencyClosureCoverageWireV1 {
559    Complete,
560    Partial {
561        #[serde(deserialize_with = "deserialize_closure_coverage_reasons")]
562        reasons: CappedSequence<DependencyClosureCoverageReasonV1>,
563    },
564    Unavailable {
565        #[serde(deserialize_with = "deserialize_closure_coverage_reasons")]
566        reasons: CappedSequence<DependencyClosureCoverageReasonV1>,
567    },
568}
569
570impl DependencyClosureCoverageWireV1 {
571    fn into_value(self) -> (DependencyClosureCoverageV1, bool) {
572        match self {
573            Self::Complete => (DependencyClosureCoverageV1::Complete, false),
574            Self::Partial { reasons } => (
575                DependencyClosureCoverageV1::Partial {
576                    reasons: reasons.values,
577                },
578                reasons.overflowed,
579            ),
580            Self::Unavailable { reasons } => (
581                DependencyClosureCoverageV1::Unavailable {
582                    reasons: reasons.values,
583                },
584                reasons.overflowed,
585            ),
586        }
587    }
588}
589
590impl<'de> Deserialize<'de> for DependencyClosureCoverageV1 {
591    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
592    where
593        D: Deserializer<'de>,
594    {
595        let (coverage, overflowed) =
596            DependencyClosureCoverageWireV1::deserialize(deserializer)?.into_value();
597        if overflowed {
598            return Err(D::Error::custom(
599                "dependency coverage reasons must be strictly ordered",
600            ));
601        }
602        Ok(coverage)
603    }
604}
605
606impl DependencyClosureCoverageV1 {
607    /// Stable incompleteness reasons, empty only for complete coverage.
608    pub fn reasons(&self) -> &[DependencyClosureCoverageReasonV1] {
609        match self {
610            Self::Complete => &[],
611            Self::Partial { reasons } | Self::Unavailable { reasons } => reasons,
612        }
613    }
614
615    /// Whether this coverage proves the exact V1 dependency closure.
616    pub const fn is_complete(&self) -> bool {
617        matches!(self, Self::Complete)
618    }
619}
620
621/// Domain-separated identity of one complete canonical closure record.
622#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
623#[serde(transparent)]
624pub struct DependencyClosureIdentityV1(InputIdentity);
625
626impl DependencyClosureIdentityV1 {
627    /// SHA-256 and canonical-preimage byte count.
628    pub const fn input_identity(&self) -> &InputIdentity {
629        &self.0
630    }
631}
632
633/// Bounded capture work, including the N+1 stop witness.
634#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
635#[serde(deny_unknown_fields)]
636pub struct DependencyClosureWorkV1 {
637    inspected_references: usize,
638    retained_references: usize,
639    normalization_bytes_inspected: usize,
640    path_components_inspected: usize,
641    dedup_probes: usize,
642    external_open_attempts: usize,
643    distinct_external_keys: usize,
644    captured_external_resources: usize,
645    external_bytes_read_hashed: u64,
646}
647
648impl DependencyClosureWorkV1 {
649    /// Declaration rows inspected, including a terminal N+1.
650    pub const fn inspected_references(self) -> usize {
651        self.inspected_references
652    }
653
654    /// Source-order declaration mappings retained.
655    pub const fn retained_references(self) -> usize {
656        self.retained_references
657    }
658
659    /// Aggregate raw locator bytes inspected, including a terminal N+1 witness.
660    pub const fn normalization_bytes_inspected(self) -> usize {
661        self.normalization_bytes_inspected
662    }
663
664    /// Aggregate path components inspected, including a terminal N+1 witness.
665    pub const fn path_components_inspected(self) -> usize {
666        self.path_components_inspected
667    }
668
669    /// Alias/deduplication probes, including a terminal N+1 witness.
670    pub const fn dedup_probes(self) -> usize {
671        self.dedup_probes
672    }
673
674    /// Rooted external open attempts; aliases do not add another attempt.
675    pub const fn external_open_attempts(self) -> usize {
676        self.external_open_attempts
677    }
678
679    /// Distinct normalized external keys admitted before rooted I/O.
680    pub const fn distinct_external_keys(self) -> usize {
681        self.distinct_external_keys
682    }
683
684    /// Distinct external keys with captured identities.
685    pub const fn captured_external_resources(self) -> usize {
686        self.captured_external_resources
687    }
688
689    /// Aggregate distinct external bytes read and hashed.
690    pub const fn external_bytes_read_hashed(self) -> u64 {
691        self.external_bytes_read_hashed
692    }
693}
694
695/// Immutable V1 closure bound to one primary input.
696#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
697pub struct DependencyClosureV1 {
698    schema: &'static str,
699    budget: ResourceClosureBudgetV1,
700    primary_input: InputIdentity,
701    coverage: DependencyClosureCoverageV1,
702    #[serde(skip_serializing_if = "Option::is_none")]
703    identity: Option<DependencyClosureIdentityV1>,
704    references: Vec<DependencyClosureReferenceV1>,
705    external_resources: Vec<ExternalResourceIdentityV1>,
706    work: DependencyClosureWorkV1,
707}
708
709#[derive(Deserialize)]
710#[serde(deny_unknown_fields)]
711struct DependencyClosureWireV1 {
712    schema: String,
713    budget: ResourceClosureBudgetV1,
714    primary_input: InputIdentity,
715    coverage: DependencyClosureCoverageWireV1,
716    #[serde(default, deserialize_with = "deserialize_optional_non_null")]
717    identity: OptionalNonNull<DependencyClosureIdentityV1>,
718    #[serde(deserialize_with = "deserialize_closure_references")]
719    references: CappedSequence<DependencyClosureReferenceV1>,
720    #[serde(deserialize_with = "deserialize_closure_external_resources")]
721    external_resources: CappedSequence<ExternalResourceIdentityV1>,
722    work: DependencyClosureWorkV1,
723}
724
725fn deserialize_closure_references<'de, D>(
726    deserializer: D,
727) -> Result<CappedSequence<DependencyClosureReferenceV1>, D::Error>
728where
729    D: Deserializer<'de>,
730{
731    deserialize_capped_sequence(deserializer, DEPENDENCY_CLOSURE_V1_MAX_REFERENCES)
732}
733
734fn deserialize_closure_external_resources<'de, D>(
735    deserializer: D,
736) -> Result<CappedSequence<ExternalResourceIdentityV1>, D::Error>
737where
738    D: Deserializer<'de>,
739{
740    deserialize_capped_sequence(deserializer, DEPENDENCY_CLOSURE_V1_MAX_EXTERNAL_RESOURCES)
741}
742
743fn deserialize_closure_coverage_reasons<'de, D>(
744    deserializer: D,
745) -> Result<CappedSequence<DependencyClosureCoverageReasonV1>, D::Error>
746where
747    D: Deserializer<'de>,
748{
749    deserialize_capped_sequence(deserializer, DEPENDENCY_CLOSURE_COVERAGE_REASON_VARIANTS)
750}
751
752#[derive(Debug, Default)]
753enum OptionalNonNull<T> {
754    #[default]
755    Missing,
756    Present(T),
757}
758
759fn deserialize_optional_non_null<'de, D, T>(deserializer: D) -> Result<OptionalNonNull<T>, D::Error>
760where
761    D: Deserializer<'de>,
762    T: Deserialize<'de>,
763{
764    T::deserialize(deserializer).map(OptionalNonNull::Present)
765}
766
767#[derive(Debug)]
768pub(crate) enum DependencyClosureDecodeError {
769    Shape(serde_json::Error),
770    Semantic(String),
771}
772
773pub(crate) fn decode_dependency_closure_v1(
774    raw: &str,
775) -> Result<DependencyClosureV1, DependencyClosureDecodeError> {
776    let wire = serde_json::from_str(raw).map_err(DependencyClosureDecodeError::Shape)?;
777    DependencyClosureV1::from_wire(wire).map_err(DependencyClosureDecodeError::Semantic)
778}
779
780impl<'de> Deserialize<'de> for DependencyClosureV1 {
781    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
782    where
783        D: Deserializer<'de>,
784    {
785        Self::from_wire(DependencyClosureWireV1::deserialize(deserializer)?)
786            .map_err(D::Error::custom)
787    }
788}
789
790impl DependencyClosureV1 {
791    fn from_wire(wire: DependencyClosureWireV1) -> Result<Self, String> {
792        if wire.schema != DEPENDENCY_CLOSURE_V1_ID {
793            return Err(format!(
794                "dependency closure schema must be {DEPENDENCY_CLOSURE_V1_ID:?}"
795            ));
796        }
797        if wire.budget != ResourceClosureBudgetV1::VALUE {
798            return Err("dependency closure budget is not immutable V1".to_owned());
799        }
800        if wire.references.overflowed {
801            return Err("dependency closure has too many references".to_owned());
802        }
803        if wire.external_resources.overflowed {
804            return Err("dependency closure has too many external resources".to_owned());
805        }
806        let (coverage, coverage_reasons_overflowed) = wire.coverage.into_value();
807        let closure = Self {
808            schema: DEPENDENCY_CLOSURE_V1_ID,
809            budget: wire.budget,
810            primary_input: wire.primary_input,
811            coverage,
812            identity: match wire.identity {
813                OptionalNonNull::Missing => None,
814                OptionalNonNull::Present(identity) => Some(identity),
815            },
816            references: wire.references.values,
817            external_resources: wire.external_resources.values,
818            work: wire.work,
819        };
820        closure.validate_wire(coverage_reasons_overflowed)?;
821        Ok(closure)
822    }
823
824    /// Safe fail-closed value for a loader that has no resource projection.
825    pub fn unavailable(primary_input: InputIdentity) -> Self {
826        Self {
827            schema: DEPENDENCY_CLOSURE_V1_ID,
828            budget: ResourceClosureBudgetV1::VALUE,
829            primary_input,
830            coverage: DependencyClosureCoverageV1::Unavailable {
831                reasons: vec![DependencyClosureCoverageReasonV1::SourceDeclarationsUnavailable],
832            },
833            identity: None,
834            references: Vec::new(),
835            external_resources: Vec::new(),
836            work: DependencyClosureWorkV1::default(),
837        }
838    }
839
840    pub(crate) fn capture_unavailable(
841        primary_input: InputIdentity,
842        source_coverage: SourceSetCoverageV1,
843    ) -> Self {
844        let mut reasons = Vec::with_capacity(2);
845        match source_coverage.state() {
846            SourceSetCoverageStateV1::Complete => {}
847            SourceSetCoverageStateV1::Partial => {
848                reasons.push(DependencyClosureCoverageReasonV1::SourceDeclarationsPartial);
849            }
850            SourceSetCoverageStateV1::Unavailable => {
851                reasons.push(DependencyClosureCoverageReasonV1::SourceDeclarationsUnavailable);
852            }
853        }
854        reasons.push(DependencyClosureCoverageReasonV1::CaptureUnavailable);
855        reasons.sort_unstable();
856        Self {
857            schema: DEPENDENCY_CLOSURE_V1_ID,
858            budget: ResourceClosureBudgetV1::VALUE,
859            primary_input,
860            coverage: DependencyClosureCoverageV1::Unavailable { reasons },
861            identity: None,
862            references: Vec::new(),
863            external_resources: Vec::new(),
864            work: DependencyClosureWorkV1::default(),
865        }
866    }
867
868    /// Semantic identity of this value contract.
869    pub const fn contract_id(&self) -> &'static str {
870        DEPENDENCY_CLOSURE_V1_ID
871    }
872
873    /// Immutable V1 budget recorded with the closure.
874    pub const fn budget(&self) -> ResourceClosureBudgetV1 {
875        self.budget
876    }
877
878    /// Exact primary input identity.
879    pub const fn primary_input(&self) -> &InputIdentity {
880        &self.primary_input
881    }
882
883    /// Closure coverage.
884    pub const fn coverage(&self) -> &DependencyClosureCoverageV1 {
885        &self.coverage
886    }
887
888    /// Exact canonical closure identity, present only for complete coverage.
889    pub const fn identity(&self) -> Option<&DependencyClosureIdentityV1> {
890        self.identity.as_ref()
891    }
892
893    /// Source-order declaration mappings retained before any terminal stop.
894    pub fn references(&self) -> &[DependencyClosureReferenceV1] {
895        &self.references
896    }
897
898    /// Distinct external identities in normalized-key order.
899    pub fn external_resources(&self) -> &[ExternalResourceIdentityV1] {
900        &self.external_resources
901    }
902
903    /// Explicit bounded capture work counters.
904    pub const fn work(&self) -> DependencyClosureWorkV1 {
905        self.work
906    }
907
908    /// Canonical fingerprint of the complete serialized closure record.
909    ///
910    /// Unlike [`Self::identity`], this is available for partial and unavailable
911    /// records and therefore does **not** prove closure completeness. It exists
912    /// so another versioned contract can commit to the exact bounded evidence
913    /// record without depending on JSON encoder details.
914    pub fn record_identity(&self) -> InputIdentity {
915        canonical_record_identity(self)
916    }
917
918    fn validate_wire(&self, coverage_reasons_overflowed: bool) -> Result<(), String> {
919        if self.budget != ResourceClosureBudgetV1::VALUE {
920            return Err("dependency closure budget is not immutable V1".to_owned());
921        }
922        if self.references.len() > DEPENDENCY_CLOSURE_V1_MAX_REFERENCES {
923            return Err("dependency closure has too many references".to_owned());
924        }
925        if self.external_resources.len() > DEPENDENCY_CLOSURE_V1_MAX_EXTERNAL_RESOURCES {
926            return Err("dependency closure has too many external resources".to_owned());
927        }
928        for (expected, reference) in self.references.iter().enumerate() {
929            if reference.source_order_index != expected {
930                return Err(format!(
931                    "dependency reference order {} is not expected prefix index {expected}",
932                    reference.source_order_index
933                ));
934            }
935            if reference.purpose != DependencyResourcePurposeV1::from_kind(reference.kind) {
936                return Err("dependency reference purpose disagrees with kind".to_owned());
937            }
938            match &reference.target {
939                DependencyReferenceTargetV1::Refused {
940                    key: Some(_),
941                    reason: DependencyResourceRefusalReasonV1::Symlink,
942                }
943                | DependencyReferenceTargetV1::Refused {
944                    key: None,
945                    reason:
946                        DependencyResourceRefusalReasonV1::Absolute
947                        | DependencyResourceRefusalReasonV1::Escaping
948                        | DependencyResourceRefusalReasonV1::Remote
949                        | DependencyResourceRefusalReasonV1::Malformed
950                        | DependencyResourceRefusalReasonV1::Oversized,
951                } => {}
952                DependencyReferenceTargetV1::Refused { .. } => {
953                    return Err(
954                        "dependency refused target has an invalid key/reason pair".to_owned()
955                    );
956                }
957                _ => {}
958            }
959        }
960        if self
961            .external_resources
962            .windows(2)
963            .any(|rows| rows[0].key >= rows[1].key)
964        {
965            return Err("dependency external resources must be strictly key ordered".to_owned());
966        }
967        let reasons = self.coverage.reasons();
968        if coverage_reasons_overflowed || reasons.windows(2).any(|rows| rows[0] >= rows[1]) {
969            return Err("dependency coverage reasons must be strictly ordered".to_owned());
970        }
971        match &self.coverage {
972            DependencyClosureCoverageV1::Complete if !reasons.is_empty() => {
973                return Err("complete dependency closure cannot have reasons".to_owned());
974            }
975            DependencyClosureCoverageV1::Partial { .. }
976            | DependencyClosureCoverageV1::Unavailable { .. }
977                if reasons.is_empty() =>
978            {
979                return Err("incomplete dependency closure requires reasons".to_owned());
980            }
981            _ => {}
982        }
983        if self.coverage.is_complete()
984            && self.references.iter().any(|reference| {
985                !matches!(
986                    reference.target,
987                    DependencyReferenceTargetV1::Primary
988                        | DependencyReferenceTargetV1::External { .. }
989                )
990            })
991        {
992            return Err("complete dependency closure has an incomplete target".to_owned());
993        }
994        for reference in &self.references {
995            if let DependencyReferenceTargetV1::External { key } = &reference.target
996                && self
997                    .external_resources
998                    .binary_search_by(|resource| resource.key.cmp(key))
999                    .is_err()
1000            {
1001                return Err("dependency reference names an absent external row".to_owned());
1002            }
1003        }
1004        for resource in &self.external_resources {
1005            if !self.references.iter().any(|reference| {
1006                matches!(
1007                    &reference.target,
1008                    DependencyReferenceTargetV1::External { key } if key == &resource.key
1009                )
1010            }) {
1011                return Err("dependency external row is not referenced".to_owned());
1012            }
1013        }
1014        let has_refused = self.references.iter().any(|reference| {
1015            matches!(
1016                reference.target,
1017                DependencyReferenceTargetV1::Refused { .. }
1018            )
1019        });
1020        let has_unavailable = self.references.iter().any(|reference| {
1021            matches!(
1022                reference.target,
1023                DependencyReferenceTargetV1::Unavailable { .. }
1024            )
1025        });
1026        if has_refused != reasons.contains(&DependencyClosureCoverageReasonV1::RefusedResource)
1027            || has_unavailable
1028                != reasons.contains(&DependencyClosureCoverageReasonV1::UnavailableResource)
1029        {
1030            return Err(
1031                "dependency closure target states disagree with coverage reasons".to_owned(),
1032            );
1033        }
1034        if reasons.contains(&DependencyClosureCoverageReasonV1::CaptureUnavailable)
1035            && (!matches!(
1036                self.coverage,
1037                DependencyClosureCoverageV1::Unavailable { .. }
1038            ) || !self.references.is_empty()
1039                || !self.external_resources.is_empty())
1040        {
1041            return Err(
1042                "capture-unavailable dependency closure must retain no closure rows".to_owned(),
1043            );
1044        }
1045        if self.coverage.is_complete() != self.identity.is_some() {
1046            return Err(
1047                "dependency closure identity must be present exactly for complete coverage"
1048                    .to_owned(),
1049            );
1050        }
1051        if let Some(identity) = &self.identity
1052            && identity
1053                != &canonical_identity(
1054                    &self.primary_input,
1055                    &self.references,
1056                    &self.external_resources,
1057                )
1058        {
1059            return Err("dependency closure identity does not match its preimage".to_owned());
1060        }
1061        let work = self.work;
1062        let retained_or_terminal = self.references.len()..=self.references.len().saturating_add(1);
1063        let mut target_keys = BTreeSet::new();
1064        for reference in &self.references {
1065            match &reference.target {
1066                DependencyReferenceTargetV1::External { key }
1067                | DependencyReferenceTargetV1::Refused { key: Some(key), .. }
1068                | DependencyReferenceTargetV1::Unavailable { key: Some(key), .. } => {
1069                    target_keys.insert(key);
1070                }
1071                DependencyReferenceTargetV1::Primary
1072                | DependencyReferenceTargetV1::Refused { key: None, .. }
1073                | DependencyReferenceTargetV1::Unavailable { key: None, .. } => {}
1074            }
1075        }
1076        let captured_bytes = self
1077            .external_resources
1078            .iter()
1079            .try_fold(0_u64, |total, row| total.checked_add(row.identity.bytes()));
1080        if work.inspected_references > DEPENDENCY_CLOSURE_V1_MAX_REFERENCES + 1
1081            || !retained_or_terminal.contains(&work.inspected_references)
1082            || (work.inspected_references != self.references.len()
1083                && !reasons.contains(&DependencyClosureCoverageReasonV1::ResourceBudgetExceeded))
1084            || work.retained_references != self.references.len()
1085            || work.normalization_bytes_inspected
1086                > DEPENDENCY_CLOSURE_V1_MAX_NORMALIZATION_BYTES + 1
1087            || work.dedup_probes > DEPENDENCY_CLOSURE_V1_MAX_DEDUP_PROBES + 1
1088            || work.dedup_probes != work.inspected_references
1089            || work.path_components_inspected
1090                > DEPENDENCY_CLOSURE_V1_MAX_REFERENCES
1091                    .saturating_mul(DEPENDENCY_CLOSURE_V1_MAX_PATH_COMPONENTS)
1092                    .saturating_add(DEPENDENCY_CLOSURE_V1_MAX_PATH_COMPONENTS + 1)
1093            || work.external_open_attempts > DEPENDENCY_CLOSURE_V1_MAX_EXTERNAL_RESOURCES
1094            || work.distinct_external_keys != target_keys.len()
1095            || work.external_open_attempts < self.external_resources.len()
1096            || work.external_open_attempts > work.distinct_external_keys
1097            || work.captured_external_resources != self.external_resources.len()
1098            || captured_bytes.is_none()
1099            || work.external_bytes_read_hashed < captured_bytes.unwrap_or(u64::MAX)
1100            || (work.external_bytes_read_hashed != captured_bytes.unwrap_or(u64::MAX)
1101                && !reasons.contains(&DependencyClosureCoverageReasonV1::ResourceBudgetExceeded))
1102            || work.external_bytes_read_hashed
1103                > DEPENDENCY_CLOSURE_V1_MAX_TOTAL_RESOURCE_BYTES.saturating_add(1)
1104        {
1105            return Err("dependency closure work counters are invalid".to_owned());
1106        }
1107        Ok(())
1108    }
1109
1110    pub(crate) fn validate_against(
1111        &self,
1112        format: SourceFormatV1,
1113        primary: &InputIdentity,
1114        resources: &SourceFactSetV1<SourceResourceReferenceV1>,
1115    ) -> Result<(), DependencyClosureError> {
1116        if &self.primary_input != primary {
1117            return Err(DependencyClosureError::PrimaryIdentityMismatch);
1118        }
1119        if self.references.len() > resources.rows().len() {
1120            return Err(DependencyClosureError::ResourceReferenceCountMismatch {
1121                facts: resources.rows().len(),
1122                closure: self.references.len(),
1123            });
1124        }
1125        for (closure, source) in self.references.iter().zip(resources.rows()) {
1126            if closure.source_order_index != source.source_order_index()
1127                || closure.kind != source.kind()
1128                || closure.purpose != DependencyResourcePurposeV1::from_kind(source.kind())
1129                || closure.source_index != source.source_index()
1130            {
1131                return Err(DependencyClosureError::ResourceReferenceMismatch {
1132                    source_order_index: closure.source_order_index,
1133                });
1134            }
1135            validate_target_against_locator(
1136                format,
1137                closure.source_order_index,
1138                &closure.target,
1139                source.locator(),
1140            )?;
1141        }
1142        if self.coverage.is_complete()
1143            && (!matches!(
1144                resources.coverage().state(),
1145                SourceSetCoverageStateV1::Complete
1146            ) || self.references.len() != resources.rows().len())
1147        {
1148            return Err(DependencyClosureError::CompleteCoverageMismatch);
1149        }
1150        if matches!(
1151            resources.coverage().state(),
1152            SourceSetCoverageStateV1::Unavailable
1153        ) && (!self.references.is_empty()
1154            || !matches!(
1155                self.coverage,
1156                DependencyClosureCoverageV1::Unavailable { .. }
1157            ))
1158        {
1159            return Err(DependencyClosureError::UnavailableCoverageMismatch);
1160        }
1161        let reasons = self.coverage.reasons();
1162        let source_reason_matches = match resources.coverage().state() {
1163            SourceSetCoverageStateV1::Complete => {
1164                !reasons.contains(&DependencyClosureCoverageReasonV1::SourceDeclarationsPartial)
1165                    && !reasons
1166                        .contains(&DependencyClosureCoverageReasonV1::SourceDeclarationsUnavailable)
1167            }
1168            SourceSetCoverageStateV1::Partial => {
1169                reasons.contains(&DependencyClosureCoverageReasonV1::SourceDeclarationsPartial)
1170                    && !reasons
1171                        .contains(&DependencyClosureCoverageReasonV1::SourceDeclarationsUnavailable)
1172            }
1173            SourceSetCoverageStateV1::Unavailable => {
1174                reasons.contains(&DependencyClosureCoverageReasonV1::SourceDeclarationsUnavailable)
1175                    && !reasons
1176                        .contains(&DependencyClosureCoverageReasonV1::SourceDeclarationsPartial)
1177            }
1178        };
1179        let capture_reason_matches = !reasons
1180            .contains(&DependencyClosureCoverageReasonV1::CaptureUnavailable)
1181            || (matches!(
1182                self.coverage,
1183                DependencyClosureCoverageV1::Unavailable { .. }
1184            ) && self.references.is_empty()
1185                && self.external_resources.is_empty()
1186                && self.identity.is_none());
1187        if !source_reason_matches || !capture_reason_matches {
1188            return Err(DependencyClosureError::CoverageReasonMismatch);
1189        }
1190        if self.coverage.is_complete() != self.identity.is_some() {
1191            return Err(DependencyClosureError::ClosureIdentityCoverageMismatch);
1192        }
1193        Ok(())
1194    }
1195}
1196
1197fn validate_target_against_locator(
1198    format: SourceFormatV1,
1199    source_order_index: usize,
1200    target: &DependencyReferenceTargetV1,
1201    locator: &SourceResourceLocatorV1,
1202) -> Result<(), DependencyClosureError> {
1203    let matches = match locator {
1204        SourceResourceLocatorV1::Relative(locator) => {
1205            let syntax = match format {
1206                SourceFormatV1::GltfJson | SourceFormatV1::Glb => ResourceKeySyntaxV1::GltfUri,
1207                SourceFormatV1::Fbx => ResourceKeySyntaxV1::ParserRelativePath,
1208            };
1209            match DependencyResourceKeyV1::from_relative(locator, syntax) {
1210                Ok(expected) => match target {
1211                    DependencyReferenceTargetV1::External { key }
1212                    | DependencyReferenceTargetV1::Unavailable { key: Some(key), .. } => {
1213                        if key != &expected {
1214                            return Err(DependencyClosureError::ResourceKeyMismatch {
1215                                source_order_index,
1216                            });
1217                        }
1218                        true
1219                    }
1220                    DependencyReferenceTargetV1::Refused {
1221                        key: Some(key),
1222                        reason: DependencyResourceRefusalReasonV1::Symlink,
1223                    } => {
1224                        if key != &expected {
1225                            return Err(DependencyClosureError::ResourceKeyMismatch {
1226                                source_order_index,
1227                            });
1228                        }
1229                        true
1230                    }
1231                    _ => false,
1232                },
1233                Err(
1234                    DependencyClosureError::ResourceKeyTooLong { .. }
1235                    | DependencyClosureError::TooManyPathComponents { .. },
1236                ) => matches!(
1237                    target,
1238                    DependencyReferenceTargetV1::Refused {
1239                        key: None,
1240                        reason: DependencyResourceRefusalReasonV1::Oversized,
1241                    }
1242                ),
1243                Err(DependencyClosureError::InvalidResourceKey) => matches!(
1244                    target,
1245                    DependencyReferenceTargetV1::Refused {
1246                        key: None,
1247                        reason: DependencyResourceRefusalReasonV1::Malformed,
1248                    }
1249                ),
1250                Err(_) => false,
1251            }
1252        }
1253        _ => matches!(
1254            (target, locator),
1255            (
1256                DependencyReferenceTargetV1::Primary,
1257                SourceResourceLocatorV1::Embedded | SourceResourceLocatorV1::DataUri
1258            ) | (
1259                DependencyReferenceTargetV1::Unavailable {
1260                    key: None,
1261                    reason: DependencyResourceUnavailableReasonV1::Missing,
1262                },
1263                SourceResourceLocatorV1::Missing
1264            ) | (
1265                DependencyReferenceTargetV1::Refused {
1266                    key: None,
1267                    reason: DependencyResourceRefusalReasonV1::Absolute
1268                },
1269                SourceResourceLocatorV1::Absolute
1270            ) | (
1271                DependencyReferenceTargetV1::Refused {
1272                    key: None,
1273                    reason: DependencyResourceRefusalReasonV1::Escaping
1274                },
1275                SourceResourceLocatorV1::Escaping
1276            ) | (
1277                DependencyReferenceTargetV1::Refused {
1278                    key: None,
1279                    reason: DependencyResourceRefusalReasonV1::Remote
1280                },
1281                SourceResourceLocatorV1::Remote
1282            ) | (
1283                DependencyReferenceTargetV1::Refused {
1284                    key: None,
1285                    reason: DependencyResourceRefusalReasonV1::Malformed
1286                },
1287                SourceResourceLocatorV1::Malformed
1288            ) | (
1289                DependencyReferenceTargetV1::Refused {
1290                    key: None,
1291                    reason: DependencyResourceRefusalReasonV1::Oversized
1292                },
1293                SourceResourceLocatorV1::Oversized
1294            )
1295        ),
1296    };
1297    if matches {
1298        Ok(())
1299    } else {
1300        Err(DependencyClosureError::ResourceReferenceMismatch { source_order_index })
1301    }
1302}
1303
1304#[derive(Debug, Clone, PartialEq, Eq)]
1305struct PendingReferenceV1 {
1306    external_key: Option<DependencyResourceKeyV1>,
1307}
1308
1309#[derive(Debug, Clone, PartialEq, Eq)]
1310enum CachedExternalOutcomeV1 {
1311    Captured(InputIdentity),
1312    Refused(DependencyResourceRefusalReasonV1),
1313    Unavailable(DependencyResourceUnavailableReasonV1),
1314}
1315
1316/// Incremental constructor used by format loaders after raw declarations are projected.
1317pub struct DependencyClosureBuilderV1 {
1318    primary_input: InputIdentity,
1319    source_coverage: SourceSetCoverageV1,
1320    expected_references: usize,
1321    references: Vec<DependencyClosureReferenceV1>,
1322    external_resources: BTreeMap<DependencyResourceKeyV1, InputIdentity>,
1323    external_keys: BTreeSet<DependencyResourceKeyV1>,
1324    external_outcomes: BTreeMap<DependencyResourceKeyV1, CachedExternalOutcomeV1>,
1325    opened_external_keys: BTreeSet<DependencyResourceKeyV1>,
1326    reasons: BTreeSet<DependencyClosureCoverageReasonV1>,
1327    work: DependencyClosureWorkV1,
1328    pending_reference: Option<PendingReferenceV1>,
1329    stopped: bool,
1330    unmodeled_domain: bool,
1331}
1332
1333impl DependencyClosureBuilderV1 {
1334    /// Begin a closure bound to the raw resource set and exact primary bytes.
1335    pub fn new(
1336        primary_input: InputIdentity,
1337        source_coverage: SourceSetCoverageV1,
1338        expected_references: usize,
1339    ) -> Self {
1340        let mut reasons = BTreeSet::new();
1341        match source_coverage.state() {
1342            SourceSetCoverageStateV1::Complete => {}
1343            SourceSetCoverageStateV1::Partial => {
1344                reasons.insert(DependencyClosureCoverageReasonV1::SourceDeclarationsPartial);
1345            }
1346            SourceSetCoverageStateV1::Unavailable => {
1347                reasons.insert(DependencyClosureCoverageReasonV1::SourceDeclarationsUnavailable);
1348            }
1349        }
1350        Self {
1351            primary_input,
1352            source_coverage,
1353            expected_references,
1354            references: Vec::with_capacity(
1355                expected_references.min(DEPENDENCY_CLOSURE_V1_MAX_REFERENCES),
1356            ),
1357            external_resources: BTreeMap::new(),
1358            external_keys: BTreeSet::new(),
1359            external_outcomes: BTreeMap::new(),
1360            opened_external_keys: BTreeSet::new(),
1361            reasons,
1362            work: DependencyClosureWorkV1::default(),
1363            pending_reference: None,
1364            stopped: false,
1365            unmodeled_domain: false,
1366        }
1367    }
1368
1369    /// Exact primary identity this builder is bound to.
1370    pub const fn primary_input(&self) -> &InputIdentity {
1371        &self.primary_input
1372    }
1373
1374    /// Capacity remaining for a single external resource read.
1375    pub const fn max_resource_bytes(&self) -> u64 {
1376        DEPENDENCY_CLOSURE_V1_MAX_RESOURCE_BYTES
1377    }
1378
1379    /// Aggregate distinct external byte capacity remaining.
1380    pub const fn remaining_external_bytes(&self) -> u64 {
1381        DEPENDENCY_CLOSURE_V1_MAX_TOTAL_RESOURCE_BYTES
1382            .saturating_sub(self.work.external_bytes_read_hashed)
1383    }
1384
1385    /// Whether a normalized key already has a captured identity.
1386    pub fn external_identity(&self, key: &DependencyResourceKeyV1) -> Option<&InputIdentity> {
1387        self.external_resources.get(key)
1388    }
1389
1390    /// Admit one source declaration before normalization/allocation/open work.
1391    ///
1392    /// Returns `false` at the first N+1 limit and permanently stops capture.
1393    pub fn begin_reference(&mut self, locator_bytes: usize, path_components: usize) -> bool {
1394        if self.stopped || self.pending_reference.is_some() {
1395            return false;
1396        }
1397        self.work.inspected_references = bounded_add(
1398            self.work.inspected_references,
1399            1,
1400            DEPENDENCY_CLOSURE_V1_MAX_REFERENCES,
1401        );
1402        self.work.normalization_bytes_inspected = bounded_add(
1403            self.work.normalization_bytes_inspected,
1404            locator_bytes,
1405            DEPENDENCY_CLOSURE_V1_MAX_NORMALIZATION_BYTES,
1406        );
1407        self.work.path_components_inspected = self
1408            .work
1409            .path_components_inspected
1410            .saturating_add(path_components.min(DEPENDENCY_CLOSURE_V1_MAX_PATH_COMPONENTS + 1));
1411        self.work.dedup_probes = bounded_add(
1412            self.work.dedup_probes,
1413            1,
1414            DEPENDENCY_CLOSURE_V1_MAX_DEDUP_PROBES,
1415        );
1416        if self.references.len() >= DEPENDENCY_CLOSURE_V1_MAX_REFERENCES
1417            || locator_bytes > DEPENDENCY_CLOSURE_V1_MAX_KEY_BYTES
1418            || path_components > DEPENDENCY_CLOSURE_V1_MAX_PATH_COMPONENTS
1419            || self.work.normalization_bytes_inspected
1420                > DEPENDENCY_CLOSURE_V1_MAX_NORMALIZATION_BYTES
1421            || self.work.dedup_probes > DEPENDENCY_CLOSURE_V1_MAX_DEDUP_PROBES
1422        {
1423            self.stop_for_budget();
1424            return false;
1425        }
1426        self.pending_reference = Some(PendingReferenceV1 { external_key: None });
1427        true
1428    }
1429
1430    /// Admit one distinct normalized external key before any rooted open.
1431    ///
1432    /// `Ok(None)` is the terminal distinct-key N+1 stop. `Ok(Some(false))`
1433    /// means the key was already admitted and its cached outcome must be reused.
1434    pub fn prepare_external_key(
1435        &mut self,
1436        key: &DependencyResourceKeyV1,
1437    ) -> Result<Option<bool>, DependencyClosureError> {
1438        let pending = self
1439            .pending_reference
1440            .as_mut()
1441            .ok_or(DependencyClosureError::ReferenceNotStarted)?;
1442        if pending.external_key.is_some() {
1443            return Err(DependencyClosureError::ExternalKeyAlreadyPrepared);
1444        }
1445        if self.external_keys.contains(key) {
1446            if !self.external_outcomes.contains_key(key) {
1447                return Err(DependencyClosureError::ExternalOutcomeMissing);
1448            }
1449            pending.external_key = Some(key.clone());
1450            return Ok(Some(false));
1451        }
1452        if self.external_keys.len() >= DEPENDENCY_CLOSURE_V1_MAX_EXTERNAL_RESOURCES {
1453            self.pending_reference = None;
1454            self.stop_for_budget();
1455            return Ok(None);
1456        }
1457        self.external_keys.insert(key.clone());
1458        pending.external_key = Some(key.clone());
1459        self.work.distinct_external_keys = self.work.distinct_external_keys.saturating_add(1);
1460        Ok(Some(true))
1461    }
1462
1463    /// Record one rooted open attempt for a newly admitted external key.
1464    ///
1465    /// # Errors
1466    ///
1467    /// Returns [`DependencyClosureError`] when the key is not prepared or was
1468    /// already opened. Alias declarations must reuse their cached outcome.
1469    pub fn record_external_open_attempt(
1470        &mut self,
1471        key: &DependencyResourceKeyV1,
1472    ) -> Result<(), DependencyClosureError> {
1473        self.require_pending_key(key)?;
1474        if self.external_outcomes.contains_key(key) {
1475            return Err(DependencyClosureError::ExternalOutcomeMismatch);
1476        }
1477        if !self.opened_external_keys.insert(key.clone()) {
1478            return Err(DependencyClosureError::DuplicateExternalOpen);
1479        }
1480        self.work.external_open_attempts = self.work.external_open_attempts.saturating_add(1);
1481        Ok(())
1482    }
1483
1484    /// Retain an embedded/data/BIN/view-backed declaration mapping.
1485    pub fn push_primary(
1486        &mut self,
1487        source_order_index: usize,
1488        kind: SourceResourceKindV1,
1489        source_index: u64,
1490    ) -> Result<(), DependencyClosureError> {
1491        self.require_reference_order(source_order_index)?;
1492        self.require_no_pending_key()?;
1493        self.push_reference(DependencyClosureReferenceV1::new(
1494            source_order_index,
1495            kind,
1496            source_index,
1497            DependencyReferenceTargetV1::Primary,
1498        ))
1499    }
1500
1501    /// Retain a declaration refused before opening.
1502    pub fn push_refused(
1503        &mut self,
1504        source_order_index: usize,
1505        kind: SourceResourceKindV1,
1506        source_index: u64,
1507        reason: DependencyResourceRefusalReasonV1,
1508    ) -> Result<(), DependencyClosureError> {
1509        self.require_reference_order(source_order_index)?;
1510        let prepared_key = self.pending_external_key()?.cloned();
1511        let key = match prepared_key {
1512            Some(key) if reason == DependencyResourceRefusalReasonV1::Symlink => {
1513                match self.external_outcomes.get(&key) {
1514                    Some(CachedExternalOutcomeV1::Refused(cached)) if *cached == reason => {}
1515                    Some(_) => return Err(DependencyClosureError::ExternalOutcomeMismatch),
1516                    None => {
1517                        self.external_outcomes
1518                            .insert(key.clone(), CachedExternalOutcomeV1::Refused(reason));
1519                    }
1520                }
1521                Some(key)
1522            }
1523            Some(_) => return Err(DependencyClosureError::ExternalOutcomeMismatch),
1524            None if reason == DependencyResourceRefusalReasonV1::Symlink => {
1525                return Err(DependencyClosureError::ExternalKeyNotPrepared);
1526            }
1527            None => None,
1528        };
1529        self.reasons
1530            .insert(DependencyClosureCoverageReasonV1::RefusedResource);
1531        self.push_reference(DependencyClosureReferenceV1::new(
1532            source_order_index,
1533            kind,
1534            source_index,
1535            DependencyReferenceTargetV1::Refused { key, reason },
1536        ))
1537    }
1538
1539    /// Retain a safe declaration whose content identity was unavailable.
1540    pub fn push_unavailable(
1541        &mut self,
1542        source_order_index: usize,
1543        kind: SourceResourceKindV1,
1544        source_index: u64,
1545        key: Option<DependencyResourceKeyV1>,
1546        reason: DependencyResourceUnavailableReasonV1,
1547    ) -> Result<(), DependencyClosureError> {
1548        self.require_reference_order(source_order_index)?;
1549        match &key {
1550            Some(key) => {
1551                self.require_pending_key(key)?;
1552                match self.external_outcomes.get(key) {
1553                    Some(CachedExternalOutcomeV1::Unavailable(cached)) if *cached == reason => {}
1554                    Some(_) => return Err(DependencyClosureError::ExternalOutcomeMismatch),
1555                    None => {
1556                        self.external_outcomes
1557                            .insert(key.clone(), CachedExternalOutcomeV1::Unavailable(reason));
1558                    }
1559                }
1560            }
1561            None => self.require_no_pending_key()?,
1562        }
1563        self.reasons
1564            .insert(DependencyClosureCoverageReasonV1::UnavailableResource);
1565        self.push_reference(DependencyClosureReferenceV1::new(
1566            source_order_index,
1567            kind,
1568            source_index,
1569            DependencyReferenceTargetV1::Unavailable { key, reason },
1570        ))
1571    }
1572
1573    /// Retain an alias mapping to an identity already captured under `key`.
1574    pub fn push_external_alias(
1575        &mut self,
1576        source_order_index: usize,
1577        kind: SourceResourceKindV1,
1578        source_index: u64,
1579        key: DependencyResourceKeyV1,
1580    ) -> Result<(), DependencyClosureError> {
1581        self.require_reference_order(source_order_index)?;
1582        self.require_pending_key(&key)?;
1583        match self.external_outcomes.get(&key) {
1584            Some(CachedExternalOutcomeV1::Captured(identity))
1585                if self.external_resources.get(&key) == Some(identity) => {}
1586            Some(_) => return Err(DependencyClosureError::ExternalOutcomeMismatch),
1587            None => return Err(DependencyClosureError::ExternalIdentityMissing),
1588        }
1589        self.push_external_reference(source_order_index, kind, source_index, key)
1590    }
1591
1592    /// Retain a newly captured key and its first declaration mapping.
1593    ///
1594    /// Returns `Ok(false)` when the first external-byte N+1 records a typed
1595    /// unavailable mapping and permanently stops capture.
1596    pub fn push_captured_external(
1597        &mut self,
1598        source_order_index: usize,
1599        kind: SourceResourceKindV1,
1600        source_index: u64,
1601        key: DependencyResourceKeyV1,
1602        identity: InputIdentity,
1603    ) -> Result<bool, DependencyClosureError> {
1604        self.require_reference_order(source_order_index)?;
1605        self.require_pending_key(&key)?;
1606        if self.external_outcomes.contains_key(&key) || self.external_resources.contains_key(&key) {
1607            return Err(DependencyClosureError::ExternalOutcomeMismatch);
1608        }
1609        if !self.opened_external_keys.contains(&key) {
1610            return Err(DependencyClosureError::ExternalKeyNotOpened);
1611        }
1612        let bytes = identity.bytes();
1613        let next_total = self.work.external_bytes_read_hashed.checked_add(bytes);
1614        if bytes > DEPENDENCY_CLOSURE_V1_MAX_RESOURCE_BYTES
1615            || next_total.is_none_or(|total| total > DEPENDENCY_CLOSURE_V1_MAX_TOTAL_RESOURCE_BYTES)
1616        {
1617            let bounded_observed = bytes.min(DEPENDENCY_CLOSURE_V1_MAX_RESOURCE_BYTES + 1);
1618            self.work.external_bytes_read_hashed = self
1619                .work
1620                .external_bytes_read_hashed
1621                .saturating_add(bounded_observed)
1622                .min(DEPENDENCY_CLOSURE_V1_MAX_TOTAL_RESOURCE_BYTES + 1);
1623            self.push_unavailable(
1624                source_order_index,
1625                kind,
1626                source_index,
1627                Some(key),
1628                DependencyResourceUnavailableReasonV1::ResourceBudgetExceeded,
1629            )?;
1630            self.stop_for_budget();
1631            return Ok(false);
1632        }
1633        self.work.external_bytes_read_hashed = next_total.unwrap_or(u64::MAX);
1634        self.work.captured_external_resources =
1635            self.work.captured_external_resources.saturating_add(1);
1636        self.external_resources
1637            .insert(key.clone(), identity.clone());
1638        self.external_outcomes
1639            .insert(key.clone(), CachedExternalOutcomeV1::Captured(identity));
1640        self.push_external_reference(source_order_index, kind, source_index, key)?;
1641        Ok(true)
1642    }
1643
1644    /// Conservatively prevent completeness for a resource-bearing domain not represented by rows.
1645    pub fn mark_unmodeled_resource_domain(&mut self) {
1646        self.unmodeled_domain = true;
1647        self.reasons
1648            .insert(DependencyClosureCoverageReasonV1::UnmodeledResourceDomain);
1649    }
1650
1651    /// Finish the immutable closure and derive its identity only when complete.
1652    ///
1653    /// # Errors
1654    ///
1655    /// Returns [`DependencyClosureError`] if a declaration was begun but no
1656    /// typed outcome was supplied.
1657    pub fn finish(self) -> Result<DependencyClosureV1, DependencyClosureError> {
1658        if self.pending_reference.is_some() {
1659            return Err(DependencyClosureError::UnfinishedReference);
1660        }
1661        if self.references.len() != self.expected_references && !self.stopped {
1662            return Err(DependencyClosureError::ReferenceCountMismatch {
1663                expected: self.expected_references,
1664                actual: self.references.len(),
1665            });
1666        }
1667        if self.references.len() > self.expected_references {
1668            return Err(DependencyClosureError::ReferenceCountMismatch {
1669                expected: self.expected_references,
1670                actual: self.references.len(),
1671            });
1672        }
1673        let coverage = match self.source_coverage.state() {
1674            SourceSetCoverageStateV1::Unavailable => DependencyClosureCoverageV1::Unavailable {
1675                reasons: self.reasons.into_iter().collect(),
1676            },
1677            SourceSetCoverageStateV1::Complete
1678                if self.reasons.is_empty()
1679                    && !self.unmodeled_domain
1680                    && self.references.len() == self.expected_references
1681                    && self.references.iter().all(|reference| {
1682                        matches!(
1683                            reference.target,
1684                            DependencyReferenceTargetV1::Primary
1685                                | DependencyReferenceTargetV1::External { .. }
1686                        )
1687                    }) =>
1688            {
1689                DependencyClosureCoverageV1::Complete
1690            }
1691            _ => DependencyClosureCoverageV1::Partial {
1692                reasons: self.reasons.into_iter().collect(),
1693            },
1694        };
1695        let external_resources = self
1696            .external_resources
1697            .into_iter()
1698            .map(|(key, identity)| ExternalResourceIdentityV1 { key, identity })
1699            .collect::<Vec<_>>();
1700        let identity = coverage.is_complete().then(|| {
1701            canonical_identity(&self.primary_input, &self.references, &external_resources)
1702        });
1703        Ok(DependencyClosureV1 {
1704            schema: DEPENDENCY_CLOSURE_V1_ID,
1705            budget: ResourceClosureBudgetV1::VALUE,
1706            primary_input: self.primary_input,
1707            coverage,
1708            identity,
1709            references: self.references,
1710            external_resources,
1711            work: self.work,
1712        })
1713    }
1714
1715    fn push_external_reference(
1716        &mut self,
1717        source_order_index: usize,
1718        kind: SourceResourceKindV1,
1719        source_index: u64,
1720        key: DependencyResourceKeyV1,
1721    ) -> Result<(), DependencyClosureError> {
1722        self.push_reference(DependencyClosureReferenceV1::new(
1723            source_order_index,
1724            kind,
1725            source_index,
1726            DependencyReferenceTargetV1::External { key },
1727        ))
1728    }
1729
1730    fn push_reference(
1731        &mut self,
1732        reference: DependencyClosureReferenceV1,
1733    ) -> Result<(), DependencyClosureError> {
1734        if self.pending_reference.is_none() {
1735            return Err(DependencyClosureError::ReferenceNotStarted);
1736        }
1737        let expected = self.references.len();
1738        if reference.source_order_index != expected {
1739            return Err(DependencyClosureError::NonCanonicalReferenceOrder {
1740                expected,
1741                actual: reference.source_order_index,
1742            });
1743        }
1744        self.pending_reference = None;
1745        self.work.retained_references = self.work.retained_references.saturating_add(1);
1746        self.references.push(reference);
1747        Ok(())
1748    }
1749
1750    fn stop_for_budget(&mut self) {
1751        self.reasons
1752            .insert(DependencyClosureCoverageReasonV1::ResourceBudgetExceeded);
1753        self.stopped = true;
1754    }
1755
1756    fn pending_external_key(
1757        &self,
1758    ) -> Result<Option<&DependencyResourceKeyV1>, DependencyClosureError> {
1759        self.pending_reference
1760            .as_ref()
1761            .map(|pending| pending.external_key.as_ref())
1762            .ok_or(DependencyClosureError::ReferenceNotStarted)
1763    }
1764
1765    fn require_no_pending_key(&self) -> Result<(), DependencyClosureError> {
1766        match self.pending_external_key()? {
1767            None => Ok(()),
1768            Some(_) => Err(DependencyClosureError::ExternalOutcomeMismatch),
1769        }
1770    }
1771
1772    fn require_pending_key(
1773        &self,
1774        key: &DependencyResourceKeyV1,
1775    ) -> Result<(), DependencyClosureError> {
1776        match self.pending_external_key()? {
1777            Some(pending) if pending == key => Ok(()),
1778            Some(_) => Err(DependencyClosureError::ExternalKeyMismatch),
1779            None => Err(DependencyClosureError::ExternalKeyNotPrepared),
1780        }
1781    }
1782
1783    fn require_reference_order(
1784        &self,
1785        source_order_index: usize,
1786    ) -> Result<(), DependencyClosureError> {
1787        let expected = self.references.len();
1788        if source_order_index == expected {
1789            Ok(())
1790        } else {
1791            Err(DependencyClosureError::NonCanonicalReferenceOrder {
1792                expected,
1793                actual: source_order_index,
1794            })
1795        }
1796    }
1797}
1798
1799fn bounded_add(current: usize, observed: usize, limit: usize) -> usize {
1800    current
1801        .saturating_add(observed)
1802        .min(limit.saturating_add(1))
1803}
1804
1805fn canonical_identity(
1806    primary: &InputIdentity,
1807    references: &[DependencyClosureReferenceV1],
1808    resources: &[ExternalResourceIdentityV1],
1809) -> DependencyClosureIdentityV1 {
1810    let mut bytes = Vec::new();
1811    encode_text(&mut bytes, DEPENDENCY_CLOSURE_V1_ID);
1812    encode_text(&mut bytes, DEPENDENCY_CLOSURE_BUDGET_V1_ID);
1813    encode_u64(&mut bytes, DEPENDENCY_CLOSURE_V1_MAX_REFERENCES as u64);
1814    encode_u64(
1815        &mut bytes,
1816        DEPENDENCY_CLOSURE_V1_MAX_EXTERNAL_RESOURCES as u64,
1817    );
1818    encode_u64(&mut bytes, DEPENDENCY_CLOSURE_V1_MAX_KEY_BYTES as u64);
1819    encode_u64(&mut bytes, DEPENDENCY_CLOSURE_V1_MAX_PATH_COMPONENTS as u64);
1820    encode_u64(
1821        &mut bytes,
1822        DEPENDENCY_CLOSURE_V1_MAX_NORMALIZATION_BYTES as u64,
1823    );
1824    encode_u64(&mut bytes, DEPENDENCY_CLOSURE_V1_MAX_RESOURCE_BYTES);
1825    encode_u64(&mut bytes, DEPENDENCY_CLOSURE_V1_MAX_TOTAL_RESOURCE_BYTES);
1826    encode_u64(&mut bytes, DEPENDENCY_CLOSURE_V1_MAX_DEDUP_PROBES as u64);
1827    bytes.push(0); // DependencyClosureCoverageV1::Complete
1828    encode_identity(&mut bytes, primary);
1829    encode_references(&mut bytes, references, |bytes, target| match target {
1830        DependencyReferenceTargetV1::Primary => bytes.push(0),
1831        DependencyReferenceTargetV1::External { key } => {
1832            bytes.push(1);
1833            encode_text(bytes, key.as_str());
1834        }
1835        DependencyReferenceTargetV1::Refused { .. }
1836        | DependencyReferenceTargetV1::Unavailable { .. } => {
1837            unreachable!("only complete reference targets enter closure identity")
1838        }
1839    });
1840    encode_external_resources(&mut bytes, resources);
1841    DependencyClosureIdentityV1(InputIdentity::from_bytes(&bytes))
1842}
1843
1844fn canonical_record_identity(closure: &DependencyClosureV1) -> InputIdentity {
1845    let mut bytes = Vec::new();
1846    encode_text(&mut bytes, "animsmith-dependency-closure-record-v1");
1847    encode_text(&mut bytes, closure.schema);
1848    encode_text(&mut bytes, closure.budget.schema);
1849    encode_u64(&mut bytes, closure.budget.max_references as u64);
1850    encode_u64(&mut bytes, closure.budget.max_external_resources as u64);
1851    encode_u64(&mut bytes, closure.budget.max_key_bytes as u64);
1852    encode_u64(&mut bytes, closure.budget.max_path_components as u64);
1853    encode_u64(&mut bytes, closure.budget.max_normalization_bytes as u64);
1854    encode_u64(&mut bytes, closure.budget.max_resource_bytes);
1855    encode_u64(&mut bytes, closure.budget.max_total_resource_bytes);
1856    encode_u64(&mut bytes, closure.budget.max_dedup_probes as u64);
1857    encode_identity(&mut bytes, &closure.primary_input);
1858    match &closure.coverage {
1859        DependencyClosureCoverageV1::Complete => bytes.push(0),
1860        DependencyClosureCoverageV1::Partial { reasons } => {
1861            bytes.push(1);
1862            encode_u64(&mut bytes, reasons.len() as u64);
1863            for reason in reasons {
1864                bytes.push(coverage_reason_tag(*reason));
1865            }
1866        }
1867        DependencyClosureCoverageV1::Unavailable { reasons } => {
1868            bytes.push(2);
1869            encode_u64(&mut bytes, reasons.len() as u64);
1870            for reason in reasons {
1871                bytes.push(coverage_reason_tag(*reason));
1872            }
1873        }
1874    }
1875    match &closure.identity {
1876        Some(identity) => {
1877            bytes.push(1);
1878            encode_identity(&mut bytes, identity.input_identity());
1879        }
1880        None => bytes.push(0),
1881    }
1882    encode_references(
1883        &mut bytes,
1884        &closure.references,
1885        |bytes, target| match target {
1886            DependencyReferenceTargetV1::Primary => bytes.push(0),
1887            DependencyReferenceTargetV1::External { key } => {
1888                bytes.push(1);
1889                encode_text(bytes, key.as_str());
1890            }
1891            DependencyReferenceTargetV1::Refused { key, reason } => {
1892                bytes.push(2);
1893                encode_optional_key(bytes, key.as_ref());
1894                bytes.push(refusal_reason_tag(*reason));
1895            }
1896            DependencyReferenceTargetV1::Unavailable { key, reason } => {
1897                bytes.push(3);
1898                encode_optional_key(bytes, key.as_ref());
1899                bytes.push(unavailable_reason_tag(*reason));
1900            }
1901        },
1902    );
1903    encode_external_resources(&mut bytes, &closure.external_resources);
1904    let work = closure.work;
1905    encode_u64(&mut bytes, work.inspected_references as u64);
1906    encode_u64(&mut bytes, work.retained_references as u64);
1907    encode_u64(&mut bytes, work.normalization_bytes_inspected as u64);
1908    encode_u64(&mut bytes, work.path_components_inspected as u64);
1909    encode_u64(&mut bytes, work.dedup_probes as u64);
1910    encode_u64(&mut bytes, work.external_open_attempts as u64);
1911    encode_u64(&mut bytes, work.distinct_external_keys as u64);
1912    encode_u64(&mut bytes, work.captured_external_resources as u64);
1913    encode_u64(&mut bytes, work.external_bytes_read_hashed);
1914    InputIdentity::from_bytes(&bytes)
1915}
1916
1917fn encode_references(
1918    bytes: &mut Vec<u8>,
1919    references: &[DependencyClosureReferenceV1],
1920    mut encode_target: impl FnMut(&mut Vec<u8>, &DependencyReferenceTargetV1),
1921) {
1922    encode_u64(bytes, references.len() as u64);
1923    for reference in references {
1924        encode_u64(bytes, reference.source_order_index as u64);
1925        bytes.push(resource_kind_tag(reference.kind));
1926        bytes.push(resource_purpose_tag(reference.purpose));
1927        encode_u64(bytes, reference.source_index);
1928        encode_target(bytes, &reference.target);
1929    }
1930}
1931
1932fn encode_external_resources(bytes: &mut Vec<u8>, resources: &[ExternalResourceIdentityV1]) {
1933    encode_u64(bytes, resources.len() as u64);
1934    for resource in resources {
1935        encode_text(bytes, resource.key.as_str());
1936        encode_identity(bytes, &resource.identity);
1937    }
1938}
1939
1940fn encode_optional_key(bytes: &mut Vec<u8>, key: Option<&DependencyResourceKeyV1>) {
1941    match key {
1942        Some(key) => {
1943            bytes.push(1);
1944            encode_text(bytes, key.as_str());
1945        }
1946        None => bytes.push(0),
1947    }
1948}
1949
1950fn coverage_reason_tag(reason: DependencyClosureCoverageReasonV1) -> u8 {
1951    match reason {
1952        DependencyClosureCoverageReasonV1::SourceDeclarationsPartial => 0,
1953        DependencyClosureCoverageReasonV1::SourceDeclarationsUnavailable => 1,
1954        DependencyClosureCoverageReasonV1::CaptureUnavailable => 2,
1955        DependencyClosureCoverageReasonV1::RefusedResource => 3,
1956        DependencyClosureCoverageReasonV1::UnavailableResource => 4,
1957        DependencyClosureCoverageReasonV1::ResourceBudgetExceeded => 5,
1958        DependencyClosureCoverageReasonV1::UnmodeledResourceDomain => 6,
1959    }
1960}
1961
1962fn refusal_reason_tag(reason: DependencyResourceRefusalReasonV1) -> u8 {
1963    match reason {
1964        DependencyResourceRefusalReasonV1::Absolute => 0,
1965        DependencyResourceRefusalReasonV1::Escaping => 1,
1966        DependencyResourceRefusalReasonV1::Remote => 2,
1967        DependencyResourceRefusalReasonV1::Malformed => 3,
1968        DependencyResourceRefusalReasonV1::Oversized => 4,
1969        DependencyResourceRefusalReasonV1::Symlink => 5,
1970    }
1971}
1972
1973fn unavailable_reason_tag(reason: DependencyResourceUnavailableReasonV1) -> u8 {
1974    match reason {
1975        DependencyResourceUnavailableReasonV1::ResourceRootUnavailable => 0,
1976        DependencyResourceUnavailableReasonV1::Missing => 1,
1977        DependencyResourceUnavailableReasonV1::Unreadable => 2,
1978        DependencyResourceUnavailableReasonV1::ResourceBudgetExceeded => 3,
1979    }
1980}
1981
1982fn encode_identity(bytes: &mut Vec<u8>, identity: &InputIdentity) {
1983    encode_text(bytes, identity.sha256());
1984    encode_u64(bytes, identity.bytes());
1985}
1986
1987fn encode_text(bytes: &mut Vec<u8>, value: &str) {
1988    encode_u64(bytes, value.len() as u64);
1989    bytes.extend_from_slice(value.as_bytes());
1990}
1991
1992fn encode_u64(bytes: &mut Vec<u8>, value: u64) {
1993    bytes.extend_from_slice(&value.to_le_bytes());
1994}
1995
1996fn resource_kind_tag(kind: SourceResourceKindV1) -> u8 {
1997    match kind {
1998        SourceResourceKindV1::Buffer => 0,
1999        SourceResourceKindV1::Image => 1,
2000        SourceResourceKindV1::Texture => 2,
2001        SourceResourceKindV1::Video => 3,
2002        SourceResourceKindV1::Cache => 4,
2003    }
2004}
2005
2006fn resource_purpose_tag(purpose: DependencyResourcePurposeV1) -> u8 {
2007    match purpose {
2008        DependencyResourcePurposeV1::LoaderEssential => 0,
2009        DependencyResourcePurposeV1::Nonessential => 1,
2010        DependencyResourcePurposeV1::TargetOnly => 2,
2011    }
2012}
2013
2014/// Invalid closure construction or source-fact binding invariant.
2015#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2016#[non_exhaustive]
2017pub enum DependencyClosureError {
2018    /// A logical key exceeded the V1 byte limit.
2019    #[error("dependency resource key is {bytes} bytes, exceeding the V1 limit of {limit}")]
2020    ResourceKeyTooLong {
2021        /// Observed UTF-8 bytes.
2022        bytes: usize,
2023        /// Public V1 limit.
2024        limit: usize,
2025    },
2026    /// A source-relative key was malformed, absolute, remote, or escaping.
2027    #[error("dependency resource key is invalid or unsafe")]
2028    InvalidResourceKey,
2029    /// A key exceeded the component limit.
2030    #[error(
2031        "dependency resource key has {components} components, exceeding the V1 limit of {limit}"
2032    )]
2033    TooManyPathComponents {
2034        /// Observed components.
2035        components: usize,
2036        /// Public V1 limit.
2037        limit: usize,
2038    },
2039    /// A declaration outcome was supplied without successful preflight.
2040    #[error("dependency reference outcome was supplied without begin_reference")]
2041    ReferenceNotStarted,
2042    /// One declaration attempted to prepare more than one external key.
2043    #[error("dependency reference already has a prepared external key")]
2044    ExternalKeyAlreadyPrepared,
2045    /// A begun declaration did not receive a typed outcome.
2046    #[error("dependency reference was begun but no outcome was supplied")]
2047    UnfinishedReference,
2048    /// Reference rows were not a zero-based source-order prefix.
2049    #[error("dependency reference order {actual} is not expected prefix index {expected}")]
2050    NonCanonicalReferenceOrder {
2051        /// Expected source-order index.
2052        expected: usize,
2053        /// Actual source-order index.
2054        actual: usize,
2055    },
2056    /// An alias referenced a key not yet captured.
2057    #[error("dependency alias references an external key without an identity")]
2058    ExternalIdentityMissing,
2059    /// A loader used an external key without admitting it through the bounded key set.
2060    #[error("dependency external key was not prepared for rooted capture")]
2061    ExternalKeyNotPrepared,
2062    /// An outcome key differed from the exact key prepared for this declaration.
2063    #[error("dependency external key does not match the prepared reference key")]
2064    ExternalKeyMismatch,
2065    /// A cached key had no terminal captured/refused/unavailable outcome.
2066    #[error("dependency external key has no cached outcome")]
2067    ExternalOutcomeMissing,
2068    /// An alias or retry contradicted the cached outcome for its normalized key.
2069    #[error("dependency external outcome contradicts the cached key outcome")]
2070    ExternalOutcomeMismatch,
2071    /// A loader supplied an external identity without recording the same capture's open.
2072    #[error("dependency external key was not opened before capture")]
2073    ExternalKeyNotOpened,
2074    /// A normalized key was opened more than once instead of reusing its cached outcome.
2075    #[error("dependency external key was opened more than once")]
2076    DuplicateExternalOpen,
2077    /// The retained reference count differed from the declared source-row count.
2078    #[error("dependency closure retained {actual} references but expected {expected}")]
2079    ReferenceCountMismatch {
2080        /// Source rows the builder was required to map.
2081        expected: usize,
2082        /// Typed reference outcomes actually retained.
2083        actual: usize,
2084    },
2085    /// Closure and raw facts used different primary identities.
2086    #[error("dependency closure primary identity does not match raw source facts")]
2087    PrimaryIdentityMismatch,
2088    /// Closure retained more reference rows than raw facts.
2089    #[error("dependency closure has {closure} references but raw facts retain {facts}")]
2090    ResourceReferenceCountMismatch {
2091        /// Raw-fact rows.
2092        facts: usize,
2093        /// Closure rows.
2094        closure: usize,
2095    },
2096    /// A closure reference did not match the raw source row at the same position.
2097    #[error("dependency reference {source_order_index} does not match raw source facts")]
2098    ResourceReferenceMismatch {
2099        /// Mismatched source-order index.
2100        source_order_index: usize,
2101    },
2102    /// A closure target key was not the format-specific normalization of its raw locator.
2103    #[error("dependency reference {source_order_index} key does not match raw source facts")]
2104    ResourceKeyMismatch {
2105        /// Mismatched source-order index.
2106        source_order_index: usize,
2107    },
2108    /// Complete closure was claimed without complete, fully mapped raw declarations.
2109    #[error("complete dependency closure does not match complete raw resource coverage")]
2110    CompleteCoverageMismatch,
2111    /// Raw declaration coverage was unavailable but closure claimed retained mappings.
2112    #[error("unavailable raw resource coverage requires unavailable empty dependency closure")]
2113    UnavailableCoverageMismatch,
2114    /// Closure coverage reasons contradicted raw declaration coverage or retained rows.
2115    #[error("dependency closure coverage reasons do not match raw resource coverage")]
2116    CoverageReasonMismatch,
2117    /// Complete-only identity presence disagreed with closure coverage.
2118    #[error("dependency closure identity must be present exactly for complete coverage")]
2119    ClosureIdentityCoverageMismatch,
2120}
2121
2122#[cfg(test)]
2123mod tests {
2124    use super::*;
2125    use crate::{
2126        SourceFactSetV1, SourceLoaderDispositionV1, SourceProvenanceV1, SourceResourceLocatorV1,
2127    };
2128
2129    fn source_resource(
2130        order: usize,
2131        kind: SourceResourceKindV1,
2132        source_index: u64,
2133        locator: SourceResourceLocatorV1,
2134    ) -> SourceResourceReferenceV1 {
2135        SourceResourceReferenceV1::new(
2136            order,
2137            kind,
2138            source_index,
2139            locator,
2140            SourceLoaderDispositionV1::Preserved,
2141            SourceProvenanceV1::format_defined(),
2142        )
2143    }
2144
2145    fn relative(value: &str) -> SourceRelativeLocatorV1 {
2146        let SourceResourceLocatorV1::Relative(value) = SourceResourceLocatorV1::classify(value)
2147        else {
2148            panic!("fixture must be safe relative")
2149        };
2150        value
2151    }
2152
2153    #[test]
2154    fn gltf_percent_aliases_normalize_but_fbx_percent_is_literal() {
2155        let escaped = relative("textures/a%20b.png");
2156        let plain = relative("textures/a b.png");
2157        assert_eq!(
2158            DependencyResourceKeyV1::from_relative(&escaped, ResourceKeySyntaxV1::GltfUri).unwrap(),
2159            DependencyResourceKeyV1::from_relative(&plain, ResourceKeySyntaxV1::GltfUri).unwrap()
2160        );
2161        assert_ne!(
2162            DependencyResourceKeyV1::from_relative(
2163                &escaped,
2164                ResourceKeySyntaxV1::ParserRelativePath
2165            )
2166            .unwrap(),
2167            DependencyResourceKeyV1::from_relative(&plain, ResourceKeySyntaxV1::ParserRelativePath)
2168                .unwrap()
2169        );
2170    }
2171
2172    #[test]
2173    fn precomputed_digest_constructor_preserves_the_canonical_identity_shape() {
2174        let identity = InputIdentity::from_sha256_digest([0xab; 32], 17);
2175        assert_eq!(identity.sha256(), "ab".repeat(32));
2176        assert_eq!(identity.bytes(), 17);
2177    }
2178
2179    #[test]
2180    fn normalized_keys_reject_encoded_escapes_controls_and_component_n_plus_one() {
2181        for value in [
2182            "",
2183            "/absolute.bin",
2184            "C:/drive.bin",
2185            "file:secret.bin",
2186            "https://example.invalid/a.bin",
2187            "a\\b.bin",
2188            "a/./b.bin",
2189            "a/../b.bin",
2190            "a?query.bin",
2191            "a#fragment.bin",
2192            "a\ncontrol.bin",
2193            "a/%2f/b",
2194            "a/%5c/b",
2195            "a/%00/b",
2196            "a/%ff/b",
2197            "a/%zz/b",
2198        ] {
2199            assert!(
2200                matches!(
2201                    DependencyResourceKeyV1::from_source_str(value, ResourceKeySyntaxV1::GltfUri),
2202                    Err(DependencyClosureError::InvalidResourceKey)
2203                ),
2204                "unexpected safe key: {value:?}"
2205            );
2206        }
2207        let at_limit = (0..DEPENDENCY_CLOSURE_V1_MAX_PATH_COMPONENTS)
2208            .map(|_| "a")
2209            .collect::<Vec<_>>()
2210            .join("/");
2211        assert!(
2212            DependencyResourceKeyV1::from_relative(
2213                &relative(&at_limit),
2214                ResourceKeySyntaxV1::ParserRelativePath
2215            )
2216            .is_ok()
2217        );
2218        let over = format!("{at_limit}/a");
2219        assert!(matches!(
2220            DependencyResourceKeyV1::from_relative(
2221                &relative(&over),
2222                ResourceKeySyntaxV1::ParserRelativePath
2223            ),
2224            Err(DependencyClosureError::TooManyPathComponents { .. })
2225        ));
2226    }
2227
2228    #[test]
2229    fn complete_closure_deduplicates_aliases_and_changes_with_external_identity() {
2230        let primary = InputIdentity::from_bytes(b"primary");
2231        let key = DependencyResourceKeyV1::from_relative(
2232            &relative("a.bin"),
2233            ResourceKeySyntaxV1::GltfUri,
2234        )
2235        .unwrap();
2236        let mut builder =
2237            DependencyClosureBuilderV1::new(primary.clone(), SourceSetCoverageV1::complete(), 2);
2238        assert!(builder.begin_reference(5, 1));
2239        assert_eq!(builder.prepare_external_key(&key).unwrap(), Some(true));
2240        builder.record_external_open_attempt(&key).unwrap();
2241        assert!(
2242            builder
2243                .push_captured_external(
2244                    0,
2245                    SourceResourceKindV1::Buffer,
2246                    0,
2247                    key.clone(),
2248                    InputIdentity::from_bytes(b"one"),
2249                )
2250                .unwrap()
2251        );
2252        assert!(builder.begin_reference(7, 1));
2253        assert_eq!(builder.prepare_external_key(&key).unwrap(), Some(false));
2254        builder
2255            .push_external_alias(1, SourceResourceKindV1::Image, 0, key.clone())
2256            .unwrap();
2257        let first = builder.finish().unwrap();
2258        assert!(first.coverage().is_complete());
2259        let record_identity = first.record_identity();
2260        assert_eq!(
2261            record_identity.sha256(),
2262            "dd3e91c7a0e1ea436c0ad538fae3de1e5d085b0b165d8727ebc1db61b8b2bbb5"
2263        );
2264        assert_eq!(record_identity.bytes(), 608);
2265        let identity = first.identity().expect("complete closure identity");
2266        assert_eq!(
2267            identity.input_identity().sha256(),
2268            "43fccaa09b2616c57863a1186b88d8d674cb404b4e99c35b18a239a4d4b782ad"
2269        );
2270        assert_eq!(identity.input_identity().bytes(), 409);
2271        assert_eq!(first.external_resources().len(), 1);
2272        assert_eq!(first.work().external_open_attempts(), 1);
2273        let wire = serde_json::to_value(&first).unwrap();
2274        assert_eq!(wire["schema"], DEPENDENCY_CLOSURE_V1_ID);
2275        assert_eq!(wire["budget"]["schema"], DEPENDENCY_CLOSURE_BUDGET_V1_ID);
2276        assert_eq!(
2277            wire["budget"]["max_external_resources"],
2278            DEPENDENCY_CLOSURE_V1_MAX_EXTERNAL_RESOURCES
2279        );
2280        assert_eq!(wire["coverage"]["state"], "complete");
2281        assert_eq!(
2282            wire["identity"]["sha256"],
2283            "43fccaa09b2616c57863a1186b88d8d674cb404b4e99c35b18a239a4d4b782ad"
2284        );
2285
2286        let round_trip: DependencyClosureV1 =
2287            serde_json::from_value(wire.clone()).expect("builder work reads back");
2288        assert_eq!(round_trip, first);
2289        for (field, value) in [
2290            ("inspected_references", serde_json::json!(0)),
2291            ("dedup_probes", serde_json::json!(0)),
2292            ("distinct_external_keys", serde_json::json!(0)),
2293            ("external_open_attempts", serde_json::json!(0)),
2294            ("external_bytes_read_hashed", serde_json::json!(0)),
2295        ] {
2296            let mut impossible = wire.clone();
2297            impossible["work"][field] = value;
2298            let error = serde_json::from_value::<DependencyClosureV1>(impossible)
2299                .expect_err("impossible complete work must fail strict readback");
2300            assert!(
2301                error
2302                    .to_string()
2303                    .contains("dependency closure work counters are invalid"),
2304                "field {field} produced {error}"
2305            );
2306        }
2307        let mut impossible_terminal = wire.clone();
2308        impossible_terminal["work"]["inspected_references"] = serde_json::json!(3);
2309        impossible_terminal["work"]["dedup_probes"] = serde_json::json!(3);
2310        let error = serde_json::from_value::<DependencyClosureV1>(impossible_terminal)
2311            .expect_err("complete work cannot claim a terminal N+1 inspection");
2312        assert!(
2313            error
2314                .to_string()
2315                .contains("dependency closure work counters are invalid"),
2316            "unexpected terminal-work error: {error}"
2317        );
2318
2319        let mut changed =
2320            DependencyClosureBuilderV1::new(primary, SourceSetCoverageV1::complete(), 2);
2321        assert!(changed.begin_reference(5, 1));
2322        let changed_key = DependencyResourceKeyV1::from_relative(
2323            &relative("a.bin"),
2324            ResourceKeySyntaxV1::GltfUri,
2325        )
2326        .unwrap();
2327        assert_eq!(
2328            changed.prepare_external_key(&changed_key).unwrap(),
2329            Some(true)
2330        );
2331        changed.record_external_open_attempt(&changed_key).unwrap();
2332        assert!(
2333            changed
2334                .push_captured_external(
2335                    0,
2336                    SourceResourceKindV1::Buffer,
2337                    0,
2338                    changed_key,
2339                    InputIdentity::from_bytes(b"two"),
2340                )
2341                .unwrap()
2342        );
2343        assert!(changed.begin_reference(7, 1));
2344        assert_eq!(changed.prepare_external_key(&key).unwrap(), Some(false));
2345        changed
2346            .push_external_alias(1, SourceResourceKindV1::Image, 0, key)
2347            .unwrap();
2348        let changed = changed.finish().unwrap();
2349        assert_eq!(first.references().len(), changed.references().len());
2350        assert_eq!(first.references()[0].kind(), changed.references()[0].kind());
2351        assert_eq!(first.references()[1].kind(), changed.references()[1].kind());
2352        assert_ne!(first.identity(), changed.identity());
2353        assert_ne!(first.record_identity(), changed.record_identity());
2354    }
2355
2356    #[test]
2357    fn partial_and_unavailable_closures_never_claim_identity() {
2358        let primary = InputIdentity::from_bytes(b"primary");
2359        let mut partial =
2360            DependencyClosureBuilderV1::new(primary.clone(), SourceSetCoverageV1::complete(), 1);
2361        assert!(partial.begin_reference(0, 0));
2362        partial
2363            .push_refused(
2364                0,
2365                SourceResourceKindV1::Image,
2366                0,
2367                DependencyResourceRefusalReasonV1::Remote,
2368            )
2369            .unwrap();
2370        let partial = partial.finish().unwrap();
2371        assert!(matches!(
2372            partial.coverage(),
2373            DependencyClosureCoverageV1::Partial { .. }
2374        ));
2375        assert!(partial.identity().is_none());
2376        let mut explicit_null_identity = serde_json::to_value(&partial).unwrap();
2377        explicit_null_identity["identity"] = serde_json::Value::Null;
2378        let error = serde_json::from_value::<DependencyClosureV1>(explicit_null_identity)
2379            .expect_err("incomplete closure identity must be absent, not null");
2380        assert!(error.to_string().contains("invalid type: null"), "{error}");
2381        let mut impossible_terminal = serde_json::to_value(&partial).unwrap();
2382        impossible_terminal["work"]["inspected_references"] = serde_json::json!(2);
2383        impossible_terminal["work"]["dedup_probes"] = serde_json::json!(2);
2384        let error = serde_json::from_value::<DependencyClosureV1>(impossible_terminal)
2385            .expect_err("non-budget partial work cannot claim N+1 inspection");
2386        assert!(
2387            error
2388                .to_string()
2389                .contains("dependency closure work counters are invalid"),
2390            "unexpected partial terminal-work error: {error}"
2391        );
2392
2393        let unavailable = DependencyClosureV1::unavailable(primary);
2394        assert!(matches!(
2395            unavailable.coverage(),
2396            DependencyClosureCoverageV1::Unavailable { .. }
2397        ));
2398        assert!(unavailable.identity().is_none());
2399    }
2400
2401    #[test]
2402    fn reference_limit_stops_at_n_plus_one_and_never_resumes() {
2403        let primary = InputIdentity::from_bytes(b"primary");
2404        let mut builder = DependencyClosureBuilderV1::new(
2405            primary,
2406            SourceSetCoverageV1::complete(),
2407            DEPENDENCY_CLOSURE_V1_MAX_REFERENCES + 2,
2408        );
2409        for index in 0..DEPENDENCY_CLOSURE_V1_MAX_REFERENCES {
2410            assert!(builder.begin_reference(0, 0));
2411            builder
2412                .push_primary(index, SourceResourceKindV1::Image, index as u64)
2413                .unwrap();
2414        }
2415        assert!(!builder.begin_reference(0, 0));
2416        assert!(!builder.begin_reference(0, 0));
2417        let closure = builder.finish().unwrap();
2418        assert_eq!(
2419            closure.references().len(),
2420            DEPENDENCY_CLOSURE_V1_MAX_REFERENCES
2421        );
2422        assert_eq!(
2423            closure.work().inspected_references(),
2424            DEPENDENCY_CLOSURE_V1_MAX_REFERENCES + 1
2425        );
2426        assert_eq!(
2427            closure.work().dedup_probes(),
2428            DEPENDENCY_CLOSURE_V1_MAX_DEDUP_PROBES + 1
2429        );
2430        assert!(matches!(
2431            closure.coverage(),
2432            DependencyClosureCoverageV1::Partial { reasons }
2433                if reasons.contains(&DependencyClosureCoverageReasonV1::ResourceBudgetExceeded)
2434        ));
2435        let wire = serde_json::to_value(&closure).unwrap();
2436        let round_trip: DependencyClosureV1 =
2437            serde_json::from_value(wire).expect("budget N+1 work reads back");
2438        assert_eq!(round_trip, closure);
2439    }
2440
2441    #[test]
2442    fn closure_binding_checks_primary_and_exact_resource_prefix() {
2443        let primary = InputIdentity::from_bytes(b"primary");
2444        let source_rows = SourceFactSetV1::complete(vec![source_resource(
2445            0,
2446            SourceResourceKindV1::Image,
2447            7,
2448            SourceResourceLocatorV1::Embedded,
2449        )]);
2450        let mut builder = DependencyClosureBuilderV1::new(
2451            primary.clone(),
2452            source_rows.coverage(),
2453            source_rows.rows().len(),
2454        );
2455        assert!(builder.begin_reference(0, 0));
2456        builder
2457            .push_primary(0, SourceResourceKindV1::Image, 7)
2458            .unwrap();
2459        let closure = builder.finish().unwrap();
2460        closure
2461            .validate_against(SourceFormatV1::Glb, &primary, &source_rows)
2462            .unwrap();
2463        assert_eq!(
2464            closure.validate_against(
2465                SourceFormatV1::Glb,
2466                &InputIdentity::from_bytes(b"other"),
2467                &source_rows,
2468            ),
2469            Err(DependencyClosureError::PrimaryIdentityMismatch)
2470        );
2471
2472        let mut wrong_target = closure.clone();
2473        wrong_target.references[0].target = DependencyReferenceTargetV1::Refused {
2474            key: None,
2475            reason: DependencyResourceRefusalReasonV1::Remote,
2476        };
2477        assert_eq!(
2478            wrong_target.validate_against(SourceFormatV1::Glb, &primary, &source_rows),
2479            Err(DependencyClosureError::ResourceReferenceMismatch {
2480                source_order_index: 0
2481            })
2482        );
2483    }
2484
2485    #[test]
2486    fn key_and_normalization_byte_limits_are_exact_and_terminal() {
2487        let at_key_limit = "a".repeat(DEPENDENCY_CLOSURE_V1_MAX_KEY_BYTES);
2488        assert!(
2489            DependencyResourceKeyV1::from_source_str(
2490                &at_key_limit,
2491                ResourceKeySyntaxV1::ParserRelativePath
2492            )
2493            .is_ok()
2494        );
2495        let over_key_limit = "a".repeat(DEPENDENCY_CLOSURE_V1_MAX_KEY_BYTES + 1);
2496        let error = DependencyResourceKeyV1::from_source_str(
2497            &over_key_limit,
2498            ResourceKeySyntaxV1::ParserRelativePath,
2499        )
2500        .unwrap_err();
2501        assert_eq!(
2502            error,
2503            DependencyClosureError::ResourceKeyTooLong {
2504                bytes: DEPENDENCY_CLOSURE_V1_MAX_KEY_BYTES + 1,
2505                limit: DEPENDENCY_CLOSURE_V1_MAX_KEY_BYTES,
2506            }
2507        );
2508
2509        let rows =
2510            DEPENDENCY_CLOSURE_V1_MAX_NORMALIZATION_BYTES / DEPENDENCY_CLOSURE_V1_MAX_KEY_BYTES;
2511        let mut builder = DependencyClosureBuilderV1::new(
2512            InputIdentity::from_bytes(b"primary"),
2513            SourceSetCoverageV1::complete(),
2514            rows + 1,
2515        );
2516        for index in 0..rows {
2517            assert!(builder.begin_reference(DEPENDENCY_CLOSURE_V1_MAX_KEY_BYTES, 1));
2518            builder
2519                .push_primary(index, SourceResourceKindV1::Image, index as u64)
2520                .unwrap();
2521        }
2522        assert!(!builder.begin_reference(1, 1));
2523        assert!(!builder.begin_reference(0, 0));
2524        let closure = builder.finish().unwrap();
2525        assert_eq!(
2526            closure.work().normalization_bytes_inspected(),
2527            DEPENDENCY_CLOSURE_V1_MAX_NORMALIZATION_BYTES + 1
2528        );
2529        assert_eq!(closure.work().inspected_references(), rows + 1);
2530        assert_eq!(closure.work().path_components_inspected(), rows + 1);
2531        assert_eq!(closure.work().dedup_probes(), rows + 1);
2532    }
2533
2534    #[test]
2535    fn distinct_external_key_limit_stops_before_n_plus_one_open() {
2536        let primary = InputIdentity::from_bytes(b"primary");
2537        let mut builder = DependencyClosureBuilderV1::new(
2538            primary,
2539            SourceSetCoverageV1::complete(),
2540            DEPENDENCY_CLOSURE_V1_MAX_EXTERNAL_RESOURCES + 1,
2541        );
2542        for index in 0..DEPENDENCY_CLOSURE_V1_MAX_EXTERNAL_RESOURCES {
2543            assert!(builder.begin_reference(8, 1));
2544            let key = DependencyResourceKeyV1::from_source_str(
2545                &format!("r{index}.bin"),
2546                ResourceKeySyntaxV1::ParserRelativePath,
2547            )
2548            .unwrap();
2549            assert_eq!(builder.prepare_external_key(&key).unwrap(), Some(true));
2550            builder.record_external_open_attempt(&key).unwrap();
2551            assert!(
2552                builder
2553                    .push_captured_external(
2554                        index,
2555                        SourceResourceKindV1::Buffer,
2556                        index as u64,
2557                        key,
2558                        InputIdentity::from_bytes(&[]),
2559                    )
2560                    .unwrap()
2561            );
2562        }
2563        assert!(builder.begin_reference(8, 1));
2564        let overflow = DependencyResourceKeyV1::from_source_str(
2565            "overflow.bin",
2566            ResourceKeySyntaxV1::ParserRelativePath,
2567        )
2568        .unwrap();
2569        assert_eq!(builder.prepare_external_key(&overflow).unwrap(), None);
2570        assert_eq!(
2571            builder.record_external_open_attempt(&overflow),
2572            Err(DependencyClosureError::ReferenceNotStarted)
2573        );
2574        let closure = builder.finish().unwrap();
2575        assert_eq!(
2576            closure.work().distinct_external_keys(),
2577            DEPENDENCY_CLOSURE_V1_MAX_EXTERNAL_RESOURCES
2578        );
2579        assert_eq!(
2580            closure.work().inspected_references(),
2581            DEPENDENCY_CLOSURE_V1_MAX_EXTERNAL_RESOURCES + 1
2582        );
2583        assert_eq!(
2584            closure.work().dedup_probes(),
2585            DEPENDENCY_CLOSURE_V1_MAX_EXTERNAL_RESOURCES + 1
2586        );
2587        assert_eq!(
2588            closure.work().external_open_attempts(),
2589            DEPENDENCY_CLOSURE_V1_MAX_EXTERNAL_RESOURCES
2590        );
2591        assert_eq!(
2592            closure.external_resources().len(),
2593            DEPENDENCY_CLOSURE_V1_MAX_EXTERNAL_RESOURCES
2594        );
2595    }
2596
2597    #[test]
2598    fn captured_external_identity_requires_the_recorded_same_capture_open() {
2599        let mut builder = DependencyClosureBuilderV1::new(
2600            InputIdentity::from_bytes(b"primary"),
2601            SourceSetCoverageV1::complete(),
2602            1,
2603        );
2604        assert!(builder.begin_reference(5, 1));
2605        let key = DependencyResourceKeyV1::from_source_str(
2606            "a.bin",
2607            ResourceKeySyntaxV1::ParserRelativePath,
2608        )
2609        .unwrap();
2610        assert_eq!(builder.prepare_external_key(&key).unwrap(), Some(true));
2611        assert_eq!(
2612            builder.push_captured_external(
2613                0,
2614                SourceResourceKindV1::Buffer,
2615                0,
2616                key.clone(),
2617                InputIdentity::from_bytes(b"bytes"),
2618            ),
2619            Err(DependencyClosureError::ExternalKeyNotOpened)
2620        );
2621        builder.record_external_open_attempt(&key).unwrap();
2622        assert!(
2623            builder
2624                .push_captured_external(
2625                    0,
2626                    SourceResourceKindV1::Buffer,
2627                    0,
2628                    key,
2629                    InputIdentity::from_bytes(b"bytes"),
2630                )
2631                .unwrap()
2632        );
2633    }
2634
2635    #[test]
2636    fn external_byte_limits_are_exact_without_allocating_fixture_payloads() {
2637        let identity = |tag: u8, bytes| InputIdentity::from_sha256_digest([tag; 32], bytes);
2638        let key = |index| {
2639            DependencyResourceKeyV1::from_source_str(
2640                &format!("r{index}.bin"),
2641                ResourceKeySyntaxV1::ParserRelativePath,
2642            )
2643            .unwrap()
2644        };
2645
2646        let mut exact = DependencyClosureBuilderV1::new(
2647            InputIdentity::from_bytes(b"primary"),
2648            SourceSetCoverageV1::complete(),
2649            4,
2650        );
2651        for index in 0..4 {
2652            assert!(exact.begin_reference(8, 1));
2653            let key = key(index);
2654            assert_eq!(exact.prepare_external_key(&key).unwrap(), Some(true));
2655            exact.record_external_open_attempt(&key).unwrap();
2656            assert!(
2657                exact
2658                    .push_captured_external(
2659                        index,
2660                        SourceResourceKindV1::Buffer,
2661                        index as u64,
2662                        key,
2663                        identity(index as u8, DEPENDENCY_CLOSURE_V1_MAX_RESOURCE_BYTES),
2664                    )
2665                    .unwrap()
2666            );
2667        }
2668        let exact = exact.finish().unwrap();
2669        assert!(exact.coverage().is_complete());
2670        assert_eq!(
2671            exact.work().external_bytes_read_hashed(),
2672            DEPENDENCY_CLOSURE_V1_MAX_TOTAL_RESOURCE_BYTES
2673        );
2674
2675        let mut per_resource_over = DependencyClosureBuilderV1::new(
2676            InputIdentity::from_bytes(b"primary"),
2677            SourceSetCoverageV1::complete(),
2678            1,
2679        );
2680        assert!(per_resource_over.begin_reference(8, 1));
2681        let over_key = key(9);
2682        assert_eq!(
2683            per_resource_over.prepare_external_key(&over_key).unwrap(),
2684            Some(true)
2685        );
2686        per_resource_over
2687            .record_external_open_attempt(&over_key)
2688            .unwrap();
2689        assert!(
2690            !per_resource_over
2691                .push_captured_external(
2692                    0,
2693                    SourceResourceKindV1::Buffer,
2694                    0,
2695                    over_key,
2696                    identity(9, DEPENDENCY_CLOSURE_V1_MAX_RESOURCE_BYTES + 1),
2697                )
2698                .unwrap()
2699        );
2700        let per_resource_over = per_resource_over.finish().unwrap();
2701        assert_eq!(per_resource_over.references().len(), 1);
2702        assert!(matches!(
2703            per_resource_over.references()[0].target(),
2704            DependencyReferenceTargetV1::Unavailable {
2705                reason: DependencyResourceUnavailableReasonV1::ResourceBudgetExceeded,
2706                ..
2707            }
2708        ));
2709        assert_eq!(
2710            per_resource_over.work().external_bytes_read_hashed(),
2711            DEPENDENCY_CLOSURE_V1_MAX_RESOURCE_BYTES + 1
2712        );
2713        assert!(matches!(
2714            per_resource_over.coverage(),
2715            DependencyClosureCoverageV1::Partial { reasons }
2716                if reasons.contains(&DependencyClosureCoverageReasonV1::ResourceBudgetExceeded)
2717                    && reasons.contains(&DependencyClosureCoverageReasonV1::UnavailableResource)
2718        ));
2719
2720        let mut aggregate_over = DependencyClosureBuilderV1::new(
2721            InputIdentity::from_bytes(b"primary"),
2722            SourceSetCoverageV1::complete(),
2723            5,
2724        );
2725        for index in 0..4 {
2726            assert!(aggregate_over.begin_reference(8, 1));
2727            let key = key(index);
2728            assert_eq!(
2729                aggregate_over.prepare_external_key(&key).unwrap(),
2730                Some(true)
2731            );
2732            aggregate_over.record_external_open_attempt(&key).unwrap();
2733            assert!(
2734                aggregate_over
2735                    .push_captured_external(
2736                        index,
2737                        SourceResourceKindV1::Buffer,
2738                        index as u64,
2739                        key,
2740                        identity(index as u8, DEPENDENCY_CLOSURE_V1_MAX_RESOURCE_BYTES),
2741                    )
2742                    .unwrap()
2743            );
2744        }
2745        assert!(aggregate_over.begin_reference(8, 1));
2746        let fifth = key(5);
2747        assert_eq!(
2748            aggregate_over.prepare_external_key(&fifth).unwrap(),
2749            Some(true)
2750        );
2751        aggregate_over.record_external_open_attempt(&fifth).unwrap();
2752        assert!(
2753            !aggregate_over
2754                .push_captured_external(4, SourceResourceKindV1::Buffer, 4, fifth, identity(5, 1),)
2755                .unwrap()
2756        );
2757        let aggregate_over = aggregate_over.finish().unwrap();
2758        assert_eq!(aggregate_over.references().len(), 5);
2759        assert_eq!(aggregate_over.external_resources().len(), 4);
2760        assert_eq!(aggregate_over.work().inspected_references(), 5);
2761        assert_eq!(
2762            aggregate_over.work().external_bytes_read_hashed(),
2763            DEPENDENCY_CLOSURE_V1_MAX_TOTAL_RESOURCE_BYTES + 1
2764        );
2765        assert!(matches!(
2766            aggregate_over.coverage(),
2767            DependencyClosureCoverageV1::Partial { reasons }
2768                if reasons.contains(&DependencyClosureCoverageReasonV1::ResourceBudgetExceeded)
2769        ));
2770    }
2771
2772    #[test]
2773    fn source_order_is_identity_bearing_while_external_rows_remain_key_sorted() {
2774        fn closure(order: [(&str, u64); 2]) -> DependencyClosureV1 {
2775            let mut builder = DependencyClosureBuilderV1::new(
2776                InputIdentity::from_bytes(b"primary"),
2777                SourceSetCoverageV1::complete(),
2778                2,
2779            );
2780            for (source_order, (name, source_index)) in order.into_iter().enumerate() {
2781                assert!(builder.begin_reference(name.len(), 1));
2782                let key = DependencyResourceKeyV1::from_source_str(
2783                    name,
2784                    ResourceKeySyntaxV1::ParserRelativePath,
2785                )
2786                .unwrap();
2787                assert_eq!(builder.prepare_external_key(&key).unwrap(), Some(true));
2788                builder.record_external_open_attempt(&key).unwrap();
2789                assert!(
2790                    builder
2791                        .push_captured_external(
2792                            source_order,
2793                            SourceResourceKindV1::Image,
2794                            source_index,
2795                            key,
2796                            InputIdentity::from_bytes(name.as_bytes()),
2797                        )
2798                        .unwrap()
2799                );
2800            }
2801            builder.finish().unwrap()
2802        }
2803
2804        let first = closure([("z.png", 0), ("a.png", 1)]);
2805        let second = closure([("a.png", 1), ("z.png", 0)]);
2806        assert_eq!(
2807            first
2808                .external_resources()
2809                .iter()
2810                .map(|row| row.key().as_str())
2811                .collect::<Vec<_>>(),
2812            vec!["a.png", "z.png"]
2813        );
2814        assert_ne!(first.identity(), second.identity());
2815    }
2816
2817    #[test]
2818    fn refused_and_unavailable_serialization_never_has_an_unsafe_spelling() {
2819        let mut builder = DependencyClosureBuilderV1::new(
2820            InputIdentity::from_bytes(b"primary"),
2821            SourceSetCoverageV1::complete(),
2822            2,
2823        );
2824        assert!(builder.begin_reference(0, 0));
2825        builder
2826            .push_refused(
2827                0,
2828                SourceResourceKindV1::Image,
2829                0,
2830                DependencyResourceRefusalReasonV1::Absolute,
2831            )
2832            .unwrap();
2833        assert!(builder.begin_reference(8, 1));
2834        let key = DependencyResourceKeyV1::from_source_str(
2835            "safe.png",
2836            ResourceKeySyntaxV1::ParserRelativePath,
2837        )
2838        .unwrap();
2839        assert_eq!(builder.prepare_external_key(&key).unwrap(), Some(true));
2840        builder
2841            .push_unavailable(
2842                1,
2843                SourceResourceKindV1::Image,
2844                1,
2845                Some(key),
2846                DependencyResourceUnavailableReasonV1::ResourceRootUnavailable,
2847            )
2848            .unwrap();
2849        let closure = builder.finish().unwrap();
2850        let json = serde_json::to_string(&closure).unwrap();
2851        let debug = format!("{closure:?}");
2852        for rendered in [json, debug] {
2853            assert!(!rendered.contains("/home/private/secret.png"));
2854            assert!(rendered.contains("safe.png"));
2855        }
2856    }
2857
2858    #[test]
2859    fn resource_purpose_is_authoritatively_derived_and_serialized() {
2860        let cases = [
2861            (
2862                SourceResourceKindV1::Buffer,
2863                DependencyResourcePurposeV1::LoaderEssential,
2864                "loader_essential",
2865            ),
2866            (
2867                SourceResourceKindV1::Image,
2868                DependencyResourcePurposeV1::Nonessential,
2869                "nonessential",
2870            ),
2871            (
2872                SourceResourceKindV1::Texture,
2873                DependencyResourcePurposeV1::Nonessential,
2874                "nonessential",
2875            ),
2876            (
2877                SourceResourceKindV1::Video,
2878                DependencyResourcePurposeV1::TargetOnly,
2879                "target_only",
2880            ),
2881            (
2882                SourceResourceKindV1::Cache,
2883                DependencyResourcePurposeV1::TargetOnly,
2884                "target_only",
2885            ),
2886        ];
2887        let mut builder = DependencyClosureBuilderV1::new(
2888            InputIdentity::from_bytes(b"primary"),
2889            SourceSetCoverageV1::complete(),
2890            cases.len(),
2891        );
2892        for (index, (kind, _, _)) in cases.iter().copied().enumerate() {
2893            assert!(builder.begin_reference(0, 0));
2894            builder.push_primary(index, kind, index as u64).unwrap();
2895        }
2896        let closure = builder.finish().unwrap();
2897        let wire = serde_json::to_value(&closure).unwrap();
2898        for (index, (_, purpose, spelling)) in cases.iter().copied().enumerate() {
2899            assert_eq!(closure.references()[index].purpose(), purpose);
2900            assert_eq!(wire["references"][index]["purpose"], spelling);
2901        }
2902
2903        let mut mutated = closure.references.clone();
2904        mutated[0].purpose = DependencyResourcePurposeV1::TargetOnly;
2905        assert_ne!(
2906            canonical_identity(
2907                closure.primary_input(),
2908                &mutated,
2909                closure.external_resources(),
2910            ),
2911            *closure.identity().unwrap()
2912        );
2913    }
2914
2915    #[test]
2916    fn raw_relative_key_binding_uses_the_source_format_normalization() {
2917        let primary = InputIdentity::from_bytes(b"primary");
2918        let raw_locator = SourceResourceLocatorV1::classify("textures/a%20b.png");
2919        let source_rows = SourceFactSetV1::complete(vec![source_resource(
2920            0,
2921            SourceResourceKindV1::Image,
2922            0,
2923            raw_locator,
2924        )]);
2925        let literal_key = DependencyResourceKeyV1::from_source_str(
2926            "textures/a%20b.png",
2927            ResourceKeySyntaxV1::ParserRelativePath,
2928        )
2929        .unwrap();
2930        let mut builder = DependencyClosureBuilderV1::new(
2931            primary.clone(),
2932            source_rows.coverage(),
2933            source_rows.rows().len(),
2934        );
2935        assert!(builder.begin_reference(20, 2));
2936        assert_eq!(
2937            builder.prepare_external_key(&literal_key).unwrap(),
2938            Some(true)
2939        );
2940        builder.record_external_open_attempt(&literal_key).unwrap();
2941        assert!(
2942            builder
2943                .push_captured_external(
2944                    0,
2945                    SourceResourceKindV1::Image,
2946                    0,
2947                    literal_key,
2948                    InputIdentity::from_bytes(b"image"),
2949                )
2950                .unwrap()
2951        );
2952        let closure = builder.finish().unwrap();
2953        closure
2954            .validate_against(SourceFormatV1::Fbx, &primary, &source_rows)
2955            .unwrap();
2956        assert_eq!(
2957            closure.validate_against(SourceFormatV1::GltfJson, &primary, &source_rows),
2958            Err(DependencyClosureError::ResourceKeyMismatch {
2959                source_order_index: 0,
2960            })
2961        );
2962
2963        let wrong_rows = SourceFactSetV1::complete(vec![source_resource(
2964            0,
2965            SourceResourceKindV1::Image,
2966            0,
2967            SourceResourceLocatorV1::classify("textures/b%20b.png"),
2968        )]);
2969        assert_eq!(
2970            closure.validate_against(SourceFormatV1::Fbx, &primary, &wrong_rows),
2971            Err(DependencyClosureError::ResourceKeyMismatch {
2972                source_order_index: 0,
2973            })
2974        );
2975    }
2976
2977    #[test]
2978    fn binding_rejects_a_wrong_missing_reason_and_wrong_source_coverage_reason() {
2979        let primary = InputIdentity::from_bytes(b"primary");
2980        let missing_rows = SourceFactSetV1::complete(vec![source_resource(
2981            0,
2982            SourceResourceKindV1::Image,
2983            0,
2984            SourceResourceLocatorV1::Missing,
2985        )]);
2986        let mut builder = DependencyClosureBuilderV1::new(
2987            primary.clone(),
2988            missing_rows.coverage(),
2989            missing_rows.rows().len(),
2990        );
2991        assert!(builder.begin_reference(0, 0));
2992        builder
2993            .push_unavailable(
2994                0,
2995                SourceResourceKindV1::Image,
2996                0,
2997                None,
2998                DependencyResourceUnavailableReasonV1::Missing,
2999            )
3000            .unwrap();
3001        let closure = builder.finish().unwrap();
3002        closure
3003            .validate_against(SourceFormatV1::GltfJson, &primary, &missing_rows)
3004            .unwrap();
3005        let mut wrong_reason = closure.clone();
3006        wrong_reason.references[0].target = DependencyReferenceTargetV1::Unavailable {
3007            key: None,
3008            reason: DependencyResourceUnavailableReasonV1::Unreadable,
3009        };
3010        assert_eq!(
3011            wrong_reason.validate_against(SourceFormatV1::GltfJson, &primary, &missing_rows),
3012            Err(DependencyClosureError::ResourceReferenceMismatch {
3013                source_order_index: 0,
3014            })
3015        );
3016
3017        let complete_rows = SourceFactSetV1::<SourceResourceReferenceV1>::complete(Vec::new());
3018        let mut wrong_coverage = DependencyClosureV1::capture_unavailable(
3019            primary.clone(),
3020            SourceSetCoverageV1::complete(),
3021        );
3022        wrong_coverage.coverage = DependencyClosureCoverageV1::Unavailable {
3023            reasons: vec![
3024                DependencyClosureCoverageReasonV1::SourceDeclarationsUnavailable,
3025                DependencyClosureCoverageReasonV1::CaptureUnavailable,
3026            ],
3027        };
3028        assert_eq!(
3029            wrong_coverage.validate_against(SourceFormatV1::GltfJson, &primary, &complete_rows,),
3030            Err(DependencyClosureError::CoverageReasonMismatch)
3031        );
3032    }
3033
3034    #[test]
3035    fn safe_symlink_refusal_retains_and_validates_only_the_normalized_key() {
3036        let primary = InputIdentity::from_bytes(b"primary");
3037        let source_rows = SourceFactSetV1::complete(vec![
3038            source_resource(
3039                0,
3040                SourceResourceKindV1::Image,
3041                0,
3042                SourceResourceLocatorV1::classify("textures/a%20b.png"),
3043            ),
3044            source_resource(
3045                1,
3046                SourceResourceKindV1::Image,
3047                1,
3048                SourceResourceLocatorV1::classify("textures/a b.png"),
3049            ),
3050        ]);
3051        let key = DependencyResourceKeyV1::from_source_str(
3052            "textures/a b.png",
3053            ResourceKeySyntaxV1::GltfUri,
3054        )
3055        .unwrap();
3056        let mut builder = DependencyClosureBuilderV1::new(
3057            primary.clone(),
3058            source_rows.coverage(),
3059            source_rows.rows().len(),
3060        );
3061        for index in 0..2 {
3062            assert!(builder.begin_reference(20, 2));
3063            assert_eq!(
3064                builder.prepare_external_key(&key).unwrap(),
3065                Some(index == 0)
3066            );
3067            builder
3068                .push_refused(
3069                    index,
3070                    SourceResourceKindV1::Image,
3071                    index as u64,
3072                    DependencyResourceRefusalReasonV1::Symlink,
3073                )
3074                .unwrap();
3075        }
3076        let closure = builder.finish().unwrap();
3077        closure
3078            .validate_against(SourceFormatV1::GltfJson, &primary, &source_rows)
3079            .unwrap();
3080        assert_eq!(closure.work().external_open_attempts(), 0);
3081        let wire = serde_json::to_value(&closure).unwrap();
3082        assert_eq!(wire["references"][0]["target"]["key"], "textures/a b.png");
3083        assert_eq!(wire["references"][1]["target"]["key"], "textures/a b.png");
3084    }
3085
3086    #[test]
3087    fn builder_rejects_multiple_keys_and_cached_outcome_contradictions() {
3088        let primary = InputIdentity::from_bytes(b"primary");
3089        let first = DependencyResourceKeyV1::from_source_str(
3090            "a.bin",
3091            ResourceKeySyntaxV1::ParserRelativePath,
3092        )
3093        .unwrap();
3094        let second = DependencyResourceKeyV1::from_source_str(
3095            "b.bin",
3096            ResourceKeySyntaxV1::ParserRelativePath,
3097        )
3098        .unwrap();
3099        let mut unavailable =
3100            DependencyClosureBuilderV1::new(primary.clone(), SourceSetCoverageV1::complete(), 2);
3101        assert!(unavailable.begin_reference(5, 1));
3102        assert_eq!(
3103            unavailable.prepare_external_key(&first).unwrap(),
3104            Some(true)
3105        );
3106        assert_eq!(
3107            unavailable.prepare_external_key(&second),
3108            Err(DependencyClosureError::ExternalKeyAlreadyPrepared)
3109        );
3110        assert_eq!(
3111            unavailable.record_external_open_attempt(&second),
3112            Err(DependencyClosureError::ExternalKeyMismatch)
3113        );
3114        assert_eq!(
3115            unavailable.push_unavailable(
3116                0,
3117                SourceResourceKindV1::Image,
3118                0,
3119                Some(second),
3120                DependencyResourceUnavailableReasonV1::Missing,
3121            ),
3122            Err(DependencyClosureError::ExternalKeyMismatch)
3123        );
3124        unavailable
3125            .push_unavailable(
3126                0,
3127                SourceResourceKindV1::Image,
3128                0,
3129                Some(first.clone()),
3130                DependencyResourceUnavailableReasonV1::Missing,
3131            )
3132            .unwrap();
3133        assert!(unavailable.begin_reference(5, 1));
3134        assert_eq!(
3135            unavailable.prepare_external_key(&first).unwrap(),
3136            Some(false)
3137        );
3138        assert_eq!(
3139            unavailable.push_unavailable(
3140                1,
3141                SourceResourceKindV1::Image,
3142                1,
3143                Some(first.clone()),
3144                DependencyResourceUnavailableReasonV1::Unreadable,
3145            ),
3146            Err(DependencyClosureError::ExternalOutcomeMismatch)
3147        );
3148        unavailable
3149            .push_unavailable(
3150                1,
3151                SourceResourceKindV1::Image,
3152                1,
3153                Some(first.clone()),
3154                DependencyResourceUnavailableReasonV1::Missing,
3155            )
3156            .unwrap();
3157        unavailable.finish().unwrap();
3158
3159        let mut captured =
3160            DependencyClosureBuilderV1::new(primary, SourceSetCoverageV1::complete(), 2);
3161        assert!(captured.begin_reference(5, 1));
3162        assert_eq!(captured.prepare_external_key(&first).unwrap(), Some(true));
3163        captured.record_external_open_attempt(&first).unwrap();
3164        assert!(
3165            captured
3166                .push_captured_external(
3167                    0,
3168                    SourceResourceKindV1::Buffer,
3169                    0,
3170                    first.clone(),
3171                    InputIdentity::from_bytes(b"one"),
3172                )
3173                .unwrap()
3174        );
3175        assert!(captured.begin_reference(5, 1));
3176        assert_eq!(captured.prepare_external_key(&first).unwrap(), Some(false));
3177        assert_eq!(
3178            captured.push_captured_external(
3179                1,
3180                SourceResourceKindV1::Buffer,
3181                1,
3182                first.clone(),
3183                InputIdentity::from_bytes(b"two"),
3184            ),
3185            Err(DependencyClosureError::ExternalOutcomeMismatch)
3186        );
3187        assert_eq!(
3188            captured.push_unavailable(
3189                1,
3190                SourceResourceKindV1::Buffer,
3191                1,
3192                Some(first.clone()),
3193                DependencyResourceUnavailableReasonV1::Missing,
3194            ),
3195            Err(DependencyClosureError::ExternalOutcomeMismatch)
3196        );
3197        captured
3198            .push_external_alias(1, SourceResourceKindV1::Buffer, 1, first)
3199            .unwrap();
3200        captured.finish().unwrap();
3201    }
3202
3203    #[test]
3204    fn finish_requires_all_expected_rows_without_a_real_terminal_stop() {
3205        let builder = DependencyClosureBuilderV1::new(
3206            InputIdentity::from_bytes(b"primary"),
3207            SourceSetCoverageV1::complete(),
3208            1,
3209        );
3210        assert_eq!(
3211            builder.finish(),
3212            Err(DependencyClosureError::ReferenceCountMismatch {
3213                expected: 1,
3214                actual: 0,
3215            })
3216        );
3217    }
3218
3219    #[test]
3220    fn terminal_work_counters_retain_each_n_plus_one_witness() {
3221        let mut path = DependencyClosureBuilderV1::new(
3222            InputIdentity::from_bytes(b"primary"),
3223            SourceSetCoverageV1::complete(),
3224            1,
3225        );
3226        assert!(!path.begin_reference(
3227            DEPENDENCY_CLOSURE_V1_MAX_KEY_BYTES,
3228            DEPENDENCY_CLOSURE_V1_MAX_PATH_COMPONENTS + 1,
3229        ));
3230        assert!(!path.begin_reference(0, 0));
3231        let path = path.finish().unwrap();
3232        assert_eq!(path.work().inspected_references(), 1);
3233        assert_eq!(
3234            path.work().normalization_bytes_inspected(),
3235            DEPENDENCY_CLOSURE_V1_MAX_KEY_BYTES
3236        );
3237        assert_eq!(
3238            path.work().path_components_inspected(),
3239            DEPENDENCY_CLOSURE_V1_MAX_PATH_COMPONENTS + 1
3240        );
3241        assert_eq!(path.work().dedup_probes(), 1);
3242
3243        let mut locator = DependencyClosureBuilderV1::new(
3244            InputIdentity::from_bytes(b"primary"),
3245            SourceSetCoverageV1::complete(),
3246            1,
3247        );
3248        assert!(!locator.begin_reference(DEPENDENCY_CLOSURE_V1_MAX_KEY_BYTES + 1, 1));
3249        let locator = locator.finish().unwrap();
3250        assert_eq!(
3251            locator.work().normalization_bytes_inspected(),
3252            DEPENDENCY_CLOSURE_V1_MAX_KEY_BYTES + 1
3253        );
3254        assert_eq!(locator.work().path_components_inspected(), 1);
3255        assert_eq!(locator.work().dedup_probes(), 1);
3256    }
3257
3258    #[test]
3259    fn closure_wire_sequences_reject_n_plus_one_before_decoding_the_sentinel() {
3260        let primary = InputIdentity::from_bytes(b"primary");
3261        let mut builder =
3262            DependencyClosureBuilderV1::new(primary, SourceSetCoverageV1::complete(), 1);
3263        assert!(builder.begin_reference(0, 0));
3264        builder
3265            .push_primary(0, SourceResourceKindV1::Buffer, 0)
3266            .unwrap();
3267        let closure = builder.finish().unwrap();
3268        let base = serde_json::to_value(&closure).unwrap();
3269
3270        let reference = base["references"][0].clone();
3271        let mut references = Vec::with_capacity(DEPENDENCY_CLOSURE_V1_MAX_REFERENCES + 1);
3272        for source_order_index in 0..DEPENDENCY_CLOSURE_V1_MAX_REFERENCES {
3273            let mut row = reference.clone();
3274            row["source_order_index"] = serde_json::json!(source_order_index);
3275            references.push(row);
3276        }
3277        let mut exact = base.clone();
3278        exact["references"] = references.clone().into();
3279        let exact_error = decode_dependency_closure_v1(&serde_json::to_string(&exact).unwrap())
3280            .expect_err("later closure semantics reject the synthetic exact-N prefix");
3281        assert!(!matches!(
3282            exact_error,
3283            DependencyClosureDecodeError::Semantic(ref reason)
3284                if reason == "dependency closure has too many references"
3285        ));
3286        references.push(serde_json::Value::Null);
3287        let mut over = base.clone();
3288        over["references"] = references.into();
3289        assert!(matches!(
3290            decode_dependency_closure_v1(&serde_json::to_string(&over).unwrap()),
3291            Err(DependencyClosureDecodeError::Semantic(reason))
3292                if reason == "dependency closure has too many references"
3293        ));
3294
3295        let external = serde_json::json!({
3296            "key": "a.bin",
3297            "identity": {"sha256": "00".repeat(32), "bytes": 0}
3298        });
3299        let mut external_resources = vec![external; DEPENDENCY_CLOSURE_V1_MAX_EXTERNAL_RESOURCES];
3300        let mut exact = base.clone();
3301        exact["external_resources"] = external_resources.clone().into();
3302        let exact_error = decode_dependency_closure_v1(&serde_json::to_string(&exact).unwrap())
3303            .expect_err("later closure semantics reject duplicate exact-N resources");
3304        assert!(!matches!(
3305            exact_error,
3306            DependencyClosureDecodeError::Semantic(ref reason)
3307                if reason == "dependency closure has too many external resources"
3308        ));
3309        external_resources.push(serde_json::Value::Null);
3310        let mut over = base.clone();
3311        over["external_resources"] = external_resources.into();
3312        assert!(matches!(
3313            decode_dependency_closure_v1(&serde_json::to_string(&over).unwrap()),
3314            Err(DependencyClosureDecodeError::Semantic(reason))
3315                if reason == "dependency closure has too many external resources"
3316        ));
3317
3318        let mut reasons = vec![
3319            serde_json::json!("source_declarations_partial"),
3320            serde_json::json!("source_declarations_unavailable"),
3321            serde_json::json!("capture_unavailable"),
3322            serde_json::json!("refused_resource"),
3323            serde_json::json!("unavailable_resource"),
3324            serde_json::json!("resource_budget_exceeded"),
3325            serde_json::json!("unmodeled_resource_domain"),
3326        ];
3327        reasons.push(serde_json::Value::Null);
3328        let mut over = base;
3329        over["coverage"] = serde_json::json!({"state": "partial", "reasons": reasons});
3330        assert!(matches!(
3331            decode_dependency_closure_v1(&serde_json::to_string(&over).unwrap()),
3332            Err(DependencyClosureDecodeError::Semantic(reason))
3333                if reason == "dependency coverage reasons must be strictly ordered"
3334        ));
3335    }
3336
3337    #[test]
3338    fn unmodeled_domain_prevents_complete_identity() {
3339        let mut builder = DependencyClosureBuilderV1::new(
3340            InputIdentity::from_bytes(b"primary"),
3341            SourceSetCoverageV1::complete(),
3342            0,
3343        );
3344        builder.mark_unmodeled_resource_domain();
3345        let closure = builder.finish().unwrap();
3346        assert!(matches!(
3347            closure.coverage(),
3348            DependencyClosureCoverageV1::Partial { reasons }
3349                if reasons == &[DependencyClosureCoverageReasonV1::UnmodeledResourceDomain]
3350        ));
3351        assert!(closure.identity().is_none());
3352    }
3353
3354    #[test]
3355    fn equal_content_at_distinct_keys_remains_two_resources() {
3356        let mut builder = DependencyClosureBuilderV1::new(
3357            InputIdentity::from_bytes(b"primary"),
3358            SourceSetCoverageV1::complete(),
3359            2,
3360        );
3361        for (index, name) in ["a.bin", "b.bin"].into_iter().enumerate() {
3362            let key = DependencyResourceKeyV1::from_source_str(
3363                name,
3364                ResourceKeySyntaxV1::ParserRelativePath,
3365            )
3366            .unwrap();
3367            assert!(builder.begin_reference(name.len(), 1));
3368            assert_eq!(builder.prepare_external_key(&key).unwrap(), Some(true));
3369            builder.record_external_open_attempt(&key).unwrap();
3370            assert!(
3371                builder
3372                    .push_captured_external(
3373                        index,
3374                        SourceResourceKindV1::Buffer,
3375                        index as u64,
3376                        key,
3377                        InputIdentity::from_bytes(b"same"),
3378                    )
3379                    .unwrap()
3380            );
3381        }
3382        let closure = builder.finish().unwrap();
3383        assert!(closure.coverage().is_complete());
3384        assert_eq!(closure.external_resources().len(), 2);
3385        assert_eq!(closure.work().external_open_attempts(), 2);
3386        assert_eq!(
3387            closure
3388                .external_resources()
3389                .iter()
3390                .map(|resource| resource.identity())
3391                .collect::<Vec<_>>(),
3392            vec![
3393                &InputIdentity::from_bytes(b"same"),
3394                &InputIdentity::from_bytes(b"same"),
3395            ]
3396        );
3397    }
3398}