Skip to main content

amiss_wire/
controls.rs

1use std::cmp::Ordering;
2use std::collections::BTreeSet;
3
4use strum::{AsRefStr, EnumIter, EnumString, IntoEnumIterator, IntoStaticStr};
5
6use crate::de::{self, Error, ErrorKind, Obj, fail};
7use crate::digest::{Digest, hj};
8use crate::json::{self, Value};
9use crate::model::{
10    ArtifactId, BranchRef, ObjectFormat, OwnerId, RepoPathText, RepositoryIdentity, TreeIdentity,
11    UtcInstant,
12};
13use crate::resolution::{
14    BlobContent, BlobContentTag, BlobMode, BlobTarget, Missing, MissingTag, Resolution,
15    ResolutionTag, Target, TargetTag,
16};
17
18/// Execution-constraint descriptor, forge-neutral action-repository
19/// identity, and closed platform grammar.
20mod execution_constraint;
21/// Trusted-time statement grammar, digest, and bounded-lifetime parser.
22mod trusted_time;
23
24pub use execution_constraint::{ConstraintPlatform, ExecutionConstraintDescriptor};
25pub use trusted_time::{STATEMENT_TTL_MAX_SECONDS, TrustedTimeStatement};
26
27pub const SCANNER_POLICY_PATH: &str = ".amiss/scanner-policy.json";
28
29const SCANNER_POLICY_SCHEMA: &str = "amiss/scanner-policy";
30const ORGANIZATION_FLOOR_SCHEMA: &str = "amiss/organization-floor";
31const DEBT_SNAPSHOT_SCHEMA: &str = "amiss/debt-snapshot";
32const WAIVER_BUNDLE_SCHEMA: &str = "amiss/waiver-bundle";
33
34const FINDING_KEY_INPUT_SCHEMA: &str = "amiss/scanner-finding-key-input";
35const FACT_SCHEMA: &str = "amiss/scanner-fact";
36pub const FINDING_KEY_DOMAIN: &str = "amiss/scanner-finding-key";
37pub const FACT_DOMAIN: &str = "amiss/scanner-fact";
38
39#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
40pub enum IncludeKind {
41    Document,
42    Tree,
43}
44
45impl IncludeKind {
46    #[must_use]
47    pub const fn as_str(self) -> &'static str {
48        match self {
49            Self::Document => "document",
50            Self::Tree => "tree",
51        }
52    }
53
54    fn decode(path: &str, value: Value) -> Result<Self, Error> {
55        match de::string(path, value)?.as_str() {
56            "document" => Ok(Self::Document),
57            "tree" => Ok(Self::Tree),
58            _ => fail(path, ErrorKind::InvalidValue),
59        }
60    }
61}
62
63#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
64pub enum Disposition {
65    Warn,
66    Fail,
67}
68
69impl Disposition {
70    fn decode(path: &str, value: Value) -> Result<Self, Error> {
71        match de::string(path, value)?.as_str() {
72            "warn" => Ok(Self::Warn),
73            "fail" => Ok(Self::Fail),
74            _ => fail(path, ErrorKind::InvalidValue),
75        }
76    }
77}
78
79#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
80pub enum Profile {
81    Observe,
82    Enforce,
83}
84
85impl Profile {
86    /// # Errors
87    ///
88    /// A value outside the closed `observe`/`enforce` pair.
89    pub fn decode(path: &str, value: Value) -> Result<Self, Error> {
90        match de::string(path, value)?.as_str() {
91            "observe" => Ok(Self::Observe),
92            "enforce" => Ok(Self::Enforce),
93            _ => fail(path, ErrorKind::InvalidValue),
94        }
95    }
96}
97
98#[derive(
99    Clone,
100    Copy,
101    Debug,
102    PartialEq,
103    Eq,
104    PartialOrd,
105    Ord,
106    AsRefStr,
107    EnumIter,
108    EnumString,
109    IntoStaticStr,
110)]
111#[strum(serialize_all = "kebab-case")]
112pub enum PromotableFindingKind {
113    ExplicitTargetMissing,
114    ExplicitTargetTypeMismatch,
115    InvalidReference,
116}
117
118impl PromotableFindingKind {
119    #[must_use]
120    pub fn as_str(self) -> &'static str {
121        self.into()
122    }
123
124    fn decode(path: &str, value: Value) -> Result<Self, Error> {
125        let raw = de::string(path, value)?;
126        raw.parse()
127            .map_err(|_unknown| Error::new(path, ErrorKind::InvalidValue))
128    }
129}
130
131#[derive(
132    Clone,
133    Copy,
134    Debug,
135    PartialEq,
136    Eq,
137    PartialOrd,
138    Ord,
139    AsRefStr,
140    EnumIter,
141    EnumString,
142    IntoStaticStr,
143)]
144#[strum(serialize_all = "kebab-case")]
145pub enum EligibleFindingKind {
146    ExplicitTargetMissing,
147    ExplicitTargetTypeMismatch,
148}
149
150impl EligibleFindingKind {
151    #[must_use]
152    pub fn as_str(self) -> &'static str {
153        self.into()
154    }
155
156    fn decode(path: &str, value: Value) -> Result<Self, Error> {
157        let raw = de::string(path, value)?;
158        raw.parse()
159            .map_err(|_unknown| Error::new(path, ErrorKind::InvalidValue))
160    }
161}
162
163#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
164pub enum SourceConstruct {
165    InlineLink,
166    FullReferenceLink,
167    CollapsedReferenceLink,
168    ShortcutReferenceLink,
169    Autolink,
170    InlineImage,
171    FullReferenceImage,
172    CollapsedReferenceImage,
173    ShortcutReferenceImage,
174}
175
176impl SourceConstruct {
177    /// Whether the consuming syntax node is an image form, which fixes the
178    /// authored target kind.
179    #[must_use]
180    pub const fn is_image(self) -> bool {
181        match self {
182            Self::InlineImage
183            | Self::FullReferenceImage
184            | Self::CollapsedReferenceImage
185            | Self::ShortcutReferenceImage => true,
186            Self::InlineLink
187            | Self::FullReferenceLink
188            | Self::CollapsedReferenceLink
189            | Self::ShortcutReferenceLink
190            | Self::Autolink => false,
191        }
192    }
193
194    #[must_use]
195    pub const fn as_str(self) -> &'static str {
196        match self {
197            Self::InlineLink => "markdown-inline-link",
198            Self::FullReferenceLink => "markdown-full-reference-link",
199            Self::CollapsedReferenceLink => "markdown-collapsed-reference-link",
200            Self::ShortcutReferenceLink => "markdown-shortcut-reference-link",
201            Self::Autolink => "markdown-autolink",
202            Self::InlineImage => "markdown-inline-image",
203            Self::FullReferenceImage => "markdown-full-reference-image",
204            Self::CollapsedReferenceImage => "markdown-collapsed-reference-image",
205            Self::ShortcutReferenceImage => "markdown-shortcut-reference-image",
206        }
207    }
208
209    fn decode(path: &str, value: Value) -> Result<Self, Error> {
210        match de::string(path, value)?.as_str() {
211            "markdown-inline-link" => Ok(Self::InlineLink),
212            "markdown-full-reference-link" => Ok(Self::FullReferenceLink),
213            "markdown-collapsed-reference-link" => Ok(Self::CollapsedReferenceLink),
214            "markdown-shortcut-reference-link" => Ok(Self::ShortcutReferenceLink),
215            "markdown-autolink" => Ok(Self::Autolink),
216            "markdown-inline-image" => Ok(Self::InlineImage),
217            "markdown-full-reference-image" => Ok(Self::FullReferenceImage),
218            "markdown-collapsed-reference-image" => Ok(Self::CollapsedReferenceImage),
219            "markdown-shortcut-reference-image" => Ok(Self::ShortcutReferenceImage),
220            _ => fail(path, ErrorKind::InvalidValue),
221        }
222    }
223}
224
225#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
226pub enum TargetKind {
227    Blob,
228    Tree,
229    Either,
230}
231
232impl TargetKind {
233    #[must_use]
234    pub const fn as_str(self) -> &'static str {
235        match self {
236            Self::Blob => "blob",
237            Self::Tree => "tree",
238            Self::Either => "either",
239        }
240    }
241
242    fn decode(path: &str, value: Value) -> Result<Self, Error> {
243        match de::string(path, value)?.as_str() {
244            "blob" => Ok(Self::Blob),
245            "tree" => Ok(Self::Tree),
246            "either" => Ok(Self::Either),
247            _ => fail(path, ErrorKind::InvalidValue),
248        }
249    }
250}
251
252#[derive(Clone, Copy, Debug, PartialEq, Eq)]
253pub enum EntryKind {
254    Blob,
255    Tree,
256    Symlink,
257    Gitlink,
258}
259
260impl EntryKind {
261    #[must_use]
262    pub const fn as_str(self) -> &'static str {
263        match self {
264            Self::Blob => "blob",
265            Self::Tree => "tree",
266            Self::Symlink => "symlink",
267            Self::Gitlink => "gitlink",
268        }
269    }
270}
271
272#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
273pub enum GitMode {
274    RegularFile,
275    ExecutableFile,
276    Tree,
277    Symlink,
278    Gitlink,
279}
280
281impl GitMode {
282    #[must_use]
283    pub const fn as_str(self) -> &'static str {
284        match self {
285            Self::RegularFile => "100644",
286            Self::ExecutableFile => "100755",
287            Self::Tree => "040000",
288            Self::Symlink => "120000",
289            Self::Gitlink => "160000",
290        }
291    }
292}
293
294#[derive(Clone, Copy, Debug, PartialEq, Eq)]
295pub enum ContentAvailability {
296    Available,
297    NotRead,
298    NotApplicable,
299    LfsPointerOnly,
300}
301
302impl ContentAvailability {
303    #[must_use]
304    pub const fn as_str(self) -> &'static str {
305        match self {
306            Self::Available => "available",
307            Self::NotRead => "not-read",
308            Self::NotApplicable => "not-applicable",
309            Self::LfsPointerOnly => "lfs-pointer-only",
310        }
311    }
312}
313
314#[derive(
315    Clone,
316    Copy,
317    Debug,
318    PartialEq,
319    Eq,
320    PartialOrd,
321    Ord,
322    AsRefStr,
323    EnumString,
324    EnumIter,
325    IntoStaticStr,
326)]
327#[strum(serialize_all = "kebab-case")]
328pub enum ResourceName {
329    GitObjectBytes,
330    GitCompressedObjectBytes,
331    AggregateGitCompressedObjectBytesPerEvaluation,
332    GitPackDirectoryEntries,
333    GitPackFiles,
334    GitPackIndexBytes,
335    AggregateGitPackIndexBytes,
336    GitDeltaDepth,
337    GitIndexBytes,
338    GitTreeEntriesPerSnapshot,
339    DocumentsPerSnapshot,
340    ControlInputBytes,
341    SelectedControlBlobBytes,
342    AggregateSelectedControlBytesPerSnapshot,
343    RepositoryPolicyEntries,
344    DebtItems,
345    WaiverItems,
346    RawPathBytes,
347    DocumentBlobBytes,
348    ReferencedTargetBlobBytes,
349    AggregateReferencedTargetBytesPerSnapshot,
350    AggregateLineFragmentEvaluationBytesPerSnapshot,
351    AggregateDocumentBytesPerSnapshot,
352    RawLinkDestinationBytes,
353    ParserNesting,
354    ParserNodesPerDocument,
355    ParserNodesPerSnapshot,
356    AggregateEmbeddedCodeEvaluationBytesPerSnapshot,
357    ReferencesPerDocument,
358    ReferencesPerSnapshot,
359    OrganizationPolicyEntries,
360    CompleteFindings,
361    TypedAnalysisErrorsRetained,
362    MachineJsonBytes,
363    PrivateTemporaryStorageBytes,
364    EvaluatorManagedMemoryBytes,
365}
366
367impl ResourceName {
368    /// Every resource name in wire-contract order.
369    #[must_use]
370    pub fn all() -> impl ExactSizeIterator<Item = Self> {
371        Self::iter()
372    }
373
374    /// The phase a resource crossing reports, from the closed partition.
375    #[must_use]
376    pub const fn phase(self) -> &'static str {
377        match self {
378            Self::ControlInputBytes
379            | Self::RepositoryPolicyEntries
380            | Self::DebtItems
381            | Self::WaiverItems
382            | Self::OrganizationPolicyEntries => "configuration",
383            Self::GitObjectBytes
384            | Self::GitCompressedObjectBytes
385            | Self::AggregateGitCompressedObjectBytesPerEvaluation
386            | Self::GitPackDirectoryEntries
387            | Self::GitPackFiles
388            | Self::GitPackIndexBytes
389            | Self::AggregateGitPackIndexBytes
390            | Self::GitDeltaDepth
391            | Self::GitIndexBytes
392            | Self::GitTreeEntriesPerSnapshot
393            | Self::RawPathBytes => "git",
394            Self::DocumentsPerSnapshot
395            | Self::DocumentBlobBytes
396            | Self::AggregateDocumentBytesPerSnapshot
397            | Self::SelectedControlBlobBytes
398            | Self::AggregateSelectedControlBytesPerSnapshot => "discovery",
399            Self::RawLinkDestinationBytes
400            | Self::ParserNesting
401            | Self::ParserNodesPerDocument
402            | Self::ParserNodesPerSnapshot
403            | Self::AggregateEmbeddedCodeEvaluationBytesPerSnapshot
404            | Self::ReferencesPerDocument
405            | Self::ReferencesPerSnapshot => "parse",
406            Self::ReferencedTargetBlobBytes
407            | Self::AggregateReferencedTargetBytesPerSnapshot
408            | Self::AggregateLineFragmentEvaluationBytesPerSnapshot => "resolution",
409            Self::CompleteFindings => "policy",
410            Self::MachineJsonBytes => "output",
411            Self::TypedAnalysisErrorsRetained
412            | Self::PrivateTemporaryStorageBytes
413            | Self::EvaluatorManagedMemoryBytes => "internal",
414        }
415    }
416
417    #[must_use]
418    pub fn as_str(self) -> &'static str {
419        self.into()
420    }
421
422    fn decode(path: &str, value: Value) -> Result<Self, Error> {
423        let raw = de::string(path, value)?;
424        let Ok(resource) = raw.parse() else {
425            return fail(path, ErrorKind::InvalidValue);
426        };
427        Ok(resource)
428    }
429}
430
431#[derive(Clone, Debug, PartialEq, Eq)]
432pub struct DocumentInclude {
433    pub path: RepoPathText,
434    pub kind: IncludeKind,
435}
436
437#[derive(Clone, Debug, PartialEq, Eq)]
438pub struct FindingDisposition {
439    pub finding_kind: PromotableFindingKind,
440    pub disposition: Disposition,
441}
442
443#[derive(Clone, Debug, PartialEq, Eq)]
444pub struct ScannerPolicy {
445    pub digest: Digest,
446    pub document_includes: Vec<DocumentInclude>,
447    pub protected_inventory: Vec<RepoPathText>,
448    pub finding_dispositions: Vec<FindingDisposition>,
449}
450
451impl ScannerPolicy {
452    /// # Errors
453    ///
454    /// Fails on strict-JSON defects, schema-shape violations, unknown fields,
455    /// invalid grammar values, and unsorted or duplicate set members.
456    pub fn parse(bytes: &[u8]) -> Result<Self, Error> {
457        let value = root(bytes)?;
458        let digest = hj(SCANNER_POLICY_SCHEMA, &value);
459        let mut obj = Obj::new("$", value)?;
460        de::const_str(
461            &obj.field("schema"),
462            obj.take("schema")?,
463            SCANNER_POLICY_SCHEMA,
464        )?;
465
466        let includes_path = obj.field("document_includes");
467        let includes = de::array(&includes_path, obj.take("document_includes")?)?;
468        let document_includes = decode_items(&includes_path, includes, 100_000, decode_include)?;
469        sorted_set(&includes_path, &document_includes, |a, b| {
470            (a.path.as_str(), a.kind).cmp(&(b.path.as_str(), b.kind))
471        })?;
472
473        let inventory_path = obj.field("protected_inventory");
474        let protected_inventory =
475            decode_path_set(&inventory_path, obj.take("protected_inventory")?)?;
476
477        let dispositions_path = obj.field("finding_dispositions");
478        let raw = de::array(&dispositions_path, obj.take("finding_dispositions")?)?;
479        let finding_dispositions =
480            decode_items(&dispositions_path, raw, 3, decode_disposition_rule)?;
481        sorted_set(&dispositions_path, &finding_dispositions, |a, b| {
482            a.finding_kind.as_str().cmp(b.finding_kind.as_str())
483        })?;
484
485        obj.finish()?;
486        Ok(Self {
487            digest,
488            document_includes,
489            protected_inventory,
490            finding_dispositions,
491        })
492    }
493}
494
495#[derive(Clone, Debug, PartialEq, Eq)]
496pub struct ResourceLimit {
497    pub resource: ResourceName,
498    pub maximum: i64,
499}
500
501#[derive(Clone, Debug, PartialEq, Eq)]
502pub struct FloorDisposition {
503    pub finding_kind: PromotableFindingKind,
504    pub disposition: Disposition,
505}
506
507#[derive(Clone, Debug, PartialEq, Eq)]
508pub struct OrganizationFloor {
509    pub digest: Digest,
510    pub floor_id: ArtifactId,
511    pub repository: RepositoryIdentity,
512    pub ref_name: BranchRef,
513    pub minimum_profile: Profile,
514    pub minimum_dispositions: Vec<FindingDisposition>,
515    pub protected_inventory: Vec<RepoPathText>,
516    pub protected_control_paths: Vec<RepoPathText>,
517    pub waivable_finding_kinds: Vec<EligibleFindingKind>,
518    pub authorized_debt_owners: Vec<OwnerId>,
519    pub authorized_waiver_issuers: Vec<OwnerId>,
520    pub resource_limits: Vec<ResourceLimit>,
521}
522
523/// A floor rejection: a schema-layer defect, or the combined
524/// `organization-policy-entries` count crossing its effective limit.
525#[derive(Clone, Debug, PartialEq, Eq)]
526pub enum FloorDefect {
527    Schema(Error),
528    Entries {
529        configured_limit: u64,
530        observed_lower_bound: u64,
531    },
532}
533
534impl From<Error> for FloorDefect {
535    fn from(error: Error) -> Self {
536        Self::Schema(error)
537    }
538}
539
540pub const ORGANIZATION_POLICY_ENTRIES_LIMIT: u64 = 100_000;
541
542impl OrganizationFloor {
543    #[must_use]
544    pub const fn schema(&self) -> &'static str {
545        ORGANIZATION_FLOOR_SCHEMA
546    }
547
548    /// # Errors
549    ///
550    /// Fails on strict-JSON defects, schema-shape violations, unknown fields,
551    /// invalid grammar values, per-resource bound violations, unsorted or
552    /// duplicate set members, and a combined entry count over the built-in
553    /// `organization-policy-entries` limit or a tighter self-declared one.
554    pub fn parse(bytes: &[u8]) -> Result<Self, FloorDefect> {
555        let value = root(bytes)?;
556        let digest = hj(ORGANIZATION_FLOOR_SCHEMA, &value);
557        let mut obj = Obj::new("$", value)?;
558        de::const_str(
559            &obj.field("schema"),
560            obj.take("schema")?,
561            ORGANIZATION_FLOOR_SCHEMA,
562        )?;
563
564        let floor_id = decode_artifact_id(&obj.field("floor_id"), obj.take("floor_id")?)?;
565        let repository = decode_repository(&obj.field("repository"), obj.take("repository")?)?;
566        let ref_name = decode_branch_ref(&obj.field("ref"), obj.take("ref")?)?;
567        let minimum_profile =
568            Profile::decode(&obj.field("minimum_profile"), obj.take("minimum_profile")?)?;
569
570        let dispositions_path = obj.field("minimum_dispositions");
571        let dispositions_raw = de::array(&dispositions_path, obj.take("minimum_dispositions")?)?;
572        let inventory_path = obj.field("protected_inventory");
573        let inventory_raw = de::array(&inventory_path, obj.take("protected_inventory")?)?;
574        let control_paths_path = obj.field("protected_control_paths");
575        let control_paths_raw =
576            de::array(&control_paths_path, obj.take("protected_control_paths")?)?;
577        let waivable_path = obj.field("waivable_finding_kinds");
578        let waivable_raw = de::array(&waivable_path, obj.take("waivable_finding_kinds")?)?;
579        let owners_path = obj.field("authorized_debt_owners");
580        let owners_raw = de::array(&owners_path, obj.take("authorized_debt_owners")?)?;
581        let issuers_path = obj.field("authorized_waiver_issuers");
582        let issuers_raw = de::array(&issuers_path, obj.take("authorized_waiver_issuers")?)?;
583        let limits_path = obj.field("resource_limits");
584        let limits_raw = de::array(&limits_path, obj.take("resource_limits")?)?;
585
586        let combined = [
587            dispositions_raw.len(),
588            inventory_raw.len(),
589            control_paths_raw.len(),
590            waivable_raw.len(),
591            owners_raw.len(),
592            issuers_raw.len(),
593            limits_raw.len(),
594        ]
595        .iter()
596        .map(|&len| u64::try_from(len).unwrap_or(u64::MAX))
597        .fold(0_u64, u64::saturating_add);
598        if combined > ORGANIZATION_POLICY_ENTRIES_LIMIT {
599            return Err(FloorDefect::Entries {
600                configured_limit: ORGANIZATION_POLICY_ENTRIES_LIMIT,
601                observed_lower_bound: ORGANIZATION_POLICY_ENTRIES_LIMIT.saturating_add(1),
602            });
603        }
604
605        let minimum_dispositions = decode_items(
606            &dispositions_path,
607            dispositions_raw,
608            3,
609            decode_disposition_rule,
610        )?;
611        sorted_set(&dispositions_path, &minimum_dispositions, |a, b| {
612            a.finding_kind.as_str().cmp(b.finding_kind.as_str())
613        })?;
614        let protected_inventory = decode_path_items(&inventory_path, inventory_raw)?;
615        let protected_control_paths = decode_path_items(&control_paths_path, control_paths_raw)?;
616        let waivable_finding_kinds =
617            decode_items(&waivable_path, waivable_raw, 2, |path, value| {
618                EligibleFindingKind::decode(path, value)
619            })?;
620        sorted_set(&waivable_path, &waivable_finding_kinds, |a, b| {
621            a.as_str().cmp(b.as_str())
622        })?;
623        let authorized_debt_owners = decode_owner_items(&owners_path, owners_raw)?;
624        let authorized_waiver_issuers = decode_owner_items(&issuers_path, issuers_raw)?;
625        let cap = ResourceName::all().len();
626        let resource_limits = decode_items(&limits_path, limits_raw, cap, decode_resource_limit)?;
627        sorted_set(&limits_path, &resource_limits, |a, b| {
628            a.resource.as_str().cmp(b.resource.as_str())
629        })?;
630
631        obj.finish()?;
632        if let Some(declared) = resource_limits
633            .iter()
634            .find(|row| row.resource == ResourceName::OrganizationPolicyEntries)
635        {
636            let declared = u64::try_from(declared.maximum).unwrap_or(u64::MAX);
637            if combined > declared {
638                return Err(FloorDefect::Entries {
639                    configured_limit: declared,
640                    observed_lower_bound: declared.saturating_add(1),
641                });
642            }
643        }
644        Ok(Self {
645            digest,
646            floor_id,
647            repository,
648            ref_name,
649            minimum_profile,
650            minimum_dispositions,
651            protected_inventory,
652            protected_control_paths,
653            waivable_finding_kinds,
654            authorized_debt_owners,
655            authorized_waiver_issuers,
656            resource_limits,
657        })
658    }
659}
660
661#[derive(Clone, Debug, PartialEq, Eq)]
662pub struct TargetIntent {
663    pub path: RepoPathText,
664    pub target_kind: TargetKind,
665    pub query_digest: Option<Digest>,
666    pub fragment_digest: Option<Digest>,
667}
668
669#[derive(Clone, Debug, PartialEq, Eq)]
670pub struct FindingScope {
671    pub document: RepoPathText,
672    pub source_construct: SourceConstruct,
673    pub normalized_target_intent: TargetIntent,
674    pub source_projection_digest: Digest,
675}
676
677#[derive(Clone, Debug, PartialEq, Eq)]
678pub struct FindingKeyInput {
679    pub finding_kind: EligibleFindingKind,
680    pub scope: FindingScope,
681}
682
683#[derive(Clone, Debug, PartialEq, Eq)]
684pub struct Fact {
685    key_input: FindingKeyInput,
686    resolution: Resolution<RepoPathText>,
687}
688
689impl Fact {
690    /// Builds a structural fact only when the key kind and resolution family agree.
691    /// Non-structural resolution families are not eligible for control items.
692    #[must_use]
693    pub fn new(key_input: FindingKeyInput, resolution: Resolution<RepoPathText>) -> Option<Self> {
694        let expected = match &resolution {
695            Resolution::Missing(_) => EligibleFindingKind::ExplicitTargetMissing,
696            Resolution::TypeMismatch(_) => EligibleFindingKind::ExplicitTargetTypeMismatch,
697            Resolution::Resolved(_)
698            | Resolution::UnsupportedTarget(_)
699            | Resolution::UnsupportedSemantics(_)
700            | Resolution::UnsupportedVersion(_)
701            | Resolution::Invalid(_)
702            | Resolution::External(_) => return None,
703        };
704        (key_input.finding_kind == expected).then_some(Self {
705            key_input,
706            resolution,
707        })
708    }
709
710    /// The finding kind fixed by the validated key and resolution family.
711    #[must_use]
712    pub const fn finding_kind(&self) -> EligibleFindingKind {
713        self.key_input.finding_kind
714    }
715
716    /// The canonical finding-key preimage embedded in this fact.
717    #[must_use]
718    pub const fn key_input(&self) -> &FindingKeyInput {
719        &self.key_input
720    }
721
722    /// The structural resolution evidence embedded in this fact.
723    #[must_use]
724    pub const fn resolution(&self) -> &Resolution<RepoPathText> {
725        &self.resolution
726    }
727}
728
729#[derive(Clone, Debug, PartialEq, Eq)]
730pub struct DebtItem {
731    pub debt_id: ArtifactId,
732    pub finding_key: Digest,
733    pub accepted_fact: Fact,
734    pub accepted_fact_digest: Digest,
735    pub owner: OwnerId,
736    pub reason: String,
737    pub created_at: UtcInstant,
738    pub expires_at: UtcInstant,
739}
740
741#[derive(Clone, Debug, PartialEq, Eq)]
742pub struct DebtSnapshot {
743    pub digest: Digest,
744    pub repository: RepositoryIdentity,
745    pub ref_name: BranchRef,
746    pub organization_floor_digest: Digest,
747    pub adoption_tree: TreeIdentity,
748    pub adoption_report_payload_digest: Digest,
749    pub created_at: UtcInstant,
750    pub items: Vec<DebtItem>,
751}
752
753impl DebtSnapshot {
754    #[must_use]
755    pub const fn schema(&self) -> &'static str {
756        DEBT_SNAPSHOT_SCHEMA
757    }
758
759    /// # Errors
760    ///
761    /// Fails on strict-JSON defects, schema-shape violations, embedded key or
762    /// fact digests that do not recompute, fact-kind/resolution inconsistencies,
763    /// causal time-order violations, and unsorted or duplicate items or keys.
764    pub fn parse(bytes: &[u8]) -> Result<Self, Error> {
765        let value = root(bytes)?;
766        let digest = hj(DEBT_SNAPSHOT_SCHEMA, &value);
767        let mut obj = Obj::new("$", value)?;
768        de::const_str(
769            &obj.field("schema"),
770            obj.take("schema")?,
771            DEBT_SNAPSHOT_SCHEMA,
772        )?;
773
774        let repository = decode_repository(&obj.field("repository"), obj.take("repository")?)?;
775        let ref_name = decode_branch_ref(&obj.field("ref"), obj.take("ref")?)?;
776        let organization_floor_digest = decode_digest(
777            &obj.field("organization_floor_digest"),
778            obj.take("organization_floor_digest")?,
779        )?;
780        let adoption_tree = decode_tree(&obj.field("adoption_tree"), obj.take("adoption_tree")?)?;
781        let adoption_report_payload_digest = decode_digest(
782            &obj.field("adoption_report_payload_digest"),
783            obj.take("adoption_report_payload_digest")?,
784        )?;
785        let created_at = decode_instant(&obj.field("created_at"), obj.take("created_at")?)?;
786
787        let items_path = obj.field("items");
788        let raw = de::array(&items_path, obj.take("items")?)?;
789        let items = decode_items(&items_path, raw, 100_000, decode_debt_item)?;
790        sorted_set(&items_path, &items, |a, b| {
791            a.debt_id.as_str().cmp(b.debt_id.as_str())
792        })?;
793        let mut keys: BTreeSet<Digest> = BTreeSet::new();
794        for item in &items {
795            if !keys.insert(item.finding_key) {
796                return fail(&items_path, ErrorKind::DuplicateMember);
797            }
798            if item.created_at > created_at {
799                return fail(&items_path, ErrorKind::Inconsistent);
800            }
801        }
802
803        obj.finish()?;
804        Ok(Self {
805            digest,
806            repository,
807            ref_name,
808            organization_floor_digest,
809            adoption_tree,
810            adoption_report_payload_digest,
811            created_at,
812            items,
813        })
814    }
815}
816
817#[derive(Clone, Debug, PartialEq, Eq)]
818pub struct WaiverItem {
819    pub waiver_id: ArtifactId,
820    pub finding_key: Digest,
821    pub authorized_fact: Fact,
822    pub authorized_fact_digest: Digest,
823    pub candidate_tree: TreeIdentity,
824    pub owner: OwnerId,
825    pub issuer: OwnerId,
826    pub reason: String,
827    pub created_at: UtcInstant,
828    pub not_before: UtcInstant,
829    pub expires_at: UtcInstant,
830}
831
832#[derive(Clone, Debug, PartialEq, Eq)]
833pub struct WaiverBundle {
834    pub digest: Digest,
835    pub repository: RepositoryIdentity,
836    pub ref_name: BranchRef,
837    pub organization_floor_digest: Digest,
838    pub created_at: UtcInstant,
839    pub items: Vec<WaiverItem>,
840}
841
842impl WaiverBundle {
843    #[must_use]
844    pub const fn schema(&self) -> &'static str {
845        WAIVER_BUNDLE_SCHEMA
846    }
847
848    /// # Errors
849    ///
850    /// Fails on strict-JSON defects, schema-shape violations, embedded key or
851    /// fact digests that do not recompute, fact-kind/resolution inconsistencies,
852    /// causal time-order violations, duplicate waiver IDs, and duplicate
853    /// `(candidate_tree, finding_key)` pairs.
854    pub fn parse(bytes: &[u8]) -> Result<Self, Error> {
855        let value = root(bytes)?;
856        let digest = hj(WAIVER_BUNDLE_SCHEMA, &value);
857        let mut obj = Obj::new("$", value)?;
858        de::const_str(
859            &obj.field("schema"),
860            obj.take("schema")?,
861            WAIVER_BUNDLE_SCHEMA,
862        )?;
863
864        let repository = decode_repository(&obj.field("repository"), obj.take("repository")?)?;
865        let ref_name = decode_branch_ref(&obj.field("ref"), obj.take("ref")?)?;
866        let organization_floor_digest = decode_digest(
867            &obj.field("organization_floor_digest"),
868            obj.take("organization_floor_digest")?,
869        )?;
870        let created_at = decode_instant(&obj.field("created_at"), obj.take("created_at")?)?;
871
872        let items_path = obj.field("items");
873        let raw = de::array(&items_path, obj.take("items")?)?;
874        let items = decode_items(&items_path, raw, 100_000, decode_waiver_item)?;
875        sorted_set(&items_path, &items, |a, b| {
876            waiver_sort_key(a).cmp(&waiver_sort_key(b))
877        })?;
878        for pair in items.windows(2) {
879            if let [left, right] = pair
880                && left.candidate_tree == right.candidate_tree
881                && left.finding_key == right.finding_key
882            {
883                return fail(&items_path, ErrorKind::DuplicateMember);
884            }
885        }
886        let mut ids: BTreeSet<&str> = BTreeSet::new();
887        for item in &items {
888            if !ids.insert(item.waiver_id.as_str()) {
889                return fail(&items_path, ErrorKind::DuplicateMember);
890            }
891            if item.created_at > created_at {
892                return fail(&items_path, ErrorKind::Inconsistent);
893            }
894        }
895
896        obj.finish()?;
897        Ok(Self {
898            digest,
899            repository,
900            ref_name,
901            organization_floor_digest,
902            created_at,
903            items,
904        })
905    }
906}
907
908fn waiver_sort_key(item: &WaiverItem) -> (ObjectFormat, &str, Digest, &str) {
909    (
910        item.candidate_tree.object_format,
911        item.candidate_tree.tree_oid.as_str(),
912        item.finding_key,
913        item.waiver_id.as_str(),
914    )
915}
916
917/// The one restricted-JSON root every control document parses through.
918///
919/// # Errors
920///
921/// Any strict-JSON defect, carried as `ErrorKind::Json`.
922pub fn root(bytes: &[u8]) -> Result<Value, Error> {
923    json::parse(bytes).map_err(|defect| Error::new("$", ErrorKind::Json(defect)))
924}
925
926fn decode_items<T>(
927    path: &str,
928    raw: Vec<Value>,
929    limit: usize,
930    decode: impl Fn(&str, Value) -> Result<T, Error>,
931) -> Result<Vec<T>, Error> {
932    if raw.len() > limit {
933        return fail(path, ErrorKind::LimitExceeded);
934    }
935    raw.into_iter()
936        .enumerate()
937        .map(|(index, value)| decode(&format!("{path}[{index}]"), value))
938        .collect()
939}
940
941fn sorted_set<T>(
942    path: &str,
943    items: &[T],
944    compare: impl Fn(&T, &T) -> Ordering,
945) -> Result<(), Error> {
946    for pair in items.windows(2) {
947        if let [left, right] = pair {
948            match compare(left, right) {
949                Ordering::Less => {}
950                Ordering::Equal => return fail(path, ErrorKind::DuplicateMember),
951                Ordering::Greater => return fail(path, ErrorKind::UnsortedSet),
952            }
953        }
954    }
955    Ok(())
956}
957
958fn decode_include(path: &str, value: Value) -> Result<DocumentInclude, Error> {
959    let mut obj = Obj::new(path, value)?;
960    let include_path = decode_repo_path(&obj.field("path"), obj.take("path")?)?;
961    let kind = IncludeKind::decode(&obj.field("kind"), obj.take("kind")?)?;
962    obj.finish()?;
963    Ok(DocumentInclude {
964        path: include_path,
965        kind,
966    })
967}
968
969fn decode_disposition_rule(path: &str, value: Value) -> Result<FindingDisposition, Error> {
970    let mut obj = Obj::new(path, value)?;
971    let finding_kind =
972        PromotableFindingKind::decode(&obj.field("finding_kind"), obj.take("finding_kind")?)?;
973    let disposition = Disposition::decode(&obj.field("disposition"), obj.take("disposition")?)?;
974    obj.finish()?;
975    Ok(FindingDisposition {
976        finding_kind,
977        disposition,
978    })
979}
980
981fn decode_resource_limit(path: &str, value: Value) -> Result<ResourceLimit, Error> {
982    let mut obj = Obj::new(path, value)?;
983    let resource = ResourceName::decode(&obj.field("resource"), obj.take("resource")?)?;
984    let maximum_path = obj.field("maximum");
985    let maximum = de::integer(&maximum_path, obj.take("maximum")?)?;
986    obj.finish()?;
987    let in_bounds = if resource == ResourceName::TypedAnalysisErrorsRetained {
988        (1..=64).contains(&maximum)
989    } else if resource == ResourceName::MachineJsonBytes {
990        maximum == 67_108_864
991    } else {
992        maximum >= 0
993    };
994    if in_bounds {
995        Ok(ResourceLimit { resource, maximum })
996    } else {
997        fail(&maximum_path, ErrorKind::InvalidValue)
998    }
999}
1000
1001fn decode_path_set(path: &str, value: Value) -> Result<Vec<RepoPathText>, Error> {
1002    decode_path_items(path, de::array(path, value)?)
1003}
1004
1005fn decode_path_items(path: &str, raw: Vec<Value>) -> Result<Vec<RepoPathText>, Error> {
1006    let paths = decode_items(path, raw, 100_000, decode_repo_path)?;
1007    sorted_set(path, &paths, |a, b| a.as_str().cmp(b.as_str()))?;
1008    Ok(paths)
1009}
1010
1011fn decode_owner_items(path: &str, raw: Vec<Value>) -> Result<Vec<OwnerId>, Error> {
1012    let owners = decode_items(path, raw, 10_000, decode_owner)?;
1013    sorted_set(path, &owners, |a, b| a.as_str().cmp(b.as_str()))?;
1014    Ok(owners)
1015}
1016
1017fn decode_repo_path(path: &str, value: Value) -> Result<RepoPathText, Error> {
1018    RepoPathText::new(de::string(path, value)?)
1019        .ok_or_else(|| Error::new(path, ErrorKind::InvalidValue))
1020}
1021
1022fn decode_artifact_id(path: &str, value: Value) -> Result<ArtifactId, Error> {
1023    ArtifactId::new(de::string(path, value)?)
1024        .ok_or_else(|| Error::new(path, ErrorKind::InvalidValue))
1025}
1026
1027fn decode_owner(path: &str, value: Value) -> Result<OwnerId, Error> {
1028    OwnerId::new(de::string(path, value)?).ok_or_else(|| Error::new(path, ErrorKind::InvalidValue))
1029}
1030
1031fn decode_branch_ref(path: &str, value: Value) -> Result<BranchRef, Error> {
1032    BranchRef::new(de::string(path, value)?)
1033        .ok_or_else(|| Error::new(path, ErrorKind::InvalidValue))
1034}
1035
1036fn decode_instant(path: &str, value: Value) -> Result<UtcInstant, Error> {
1037    UtcInstant::new(de::string(path, value)?)
1038        .ok_or_else(|| Error::new(path, ErrorKind::InvalidValue))
1039}
1040
1041fn decode_digest(path: &str, value: Value) -> Result<Digest, Error> {
1042    let raw = de::string(path, value)?;
1043    Digest::from_wire(&raw).ok_or_else(|| Error::new(path, ErrorKind::InvalidValue))
1044}
1045
1046fn decode_nullable_digest(path: &str, value: Value) -> Result<Option<Digest>, Error> {
1047    de::nullable(value)
1048        .map(|v| decode_digest(path, v))
1049        .transpose()
1050}
1051
1052pub(crate) fn decode_repository(path: &str, value: Value) -> Result<RepositoryIdentity, Error> {
1053    let mut obj = Obj::new(path, value)?;
1054    let host = de::string(&obj.field("host"), obj.take("host")?)?;
1055    let owner = de::string(&obj.field("owner"), obj.take("owner")?)?;
1056    let name = de::string(&obj.field("name"), obj.take("name")?)?;
1057    obj.finish()?;
1058    RepositoryIdentity::new(host, owner, name)
1059        .ok_or_else(|| Error::new(path, ErrorKind::InvalidValue))
1060}
1061
1062pub(crate) fn decode_provider_run_id(path: &str, value: Value) -> Result<String, Error> {
1063    let raw = de::string(path, value)?;
1064    let bytes = raw.as_bytes();
1065    let allowed = |byte: &u8| {
1066        byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'/' | b'-')
1067    };
1068    if bytes.is_empty()
1069        || bytes.len() > 128
1070        || !bytes.first().is_some_and(u8::is_ascii_alphanumeric)
1071        || !bytes.last().is_some_and(u8::is_ascii_alphanumeric)
1072        || !bytes.iter().all(allowed)
1073    {
1074        return fail(path, ErrorKind::InvalidValue);
1075    }
1076    Ok(raw)
1077}
1078
1079pub(crate) fn decode_provider_id(path: &str, value: Value) -> Result<String, Error> {
1080    let raw = de::string(path, value)?;
1081    if ArtifactId::new(raw.clone()).is_some() {
1082        Ok(raw)
1083    } else {
1084        fail(path, ErrorKind::InvalidValue)
1085    }
1086}
1087
1088fn decode_tree(path: &str, value: Value) -> Result<TreeIdentity, Error> {
1089    let mut obj = Obj::new(path, value)?;
1090    let format_path = obj.field("object_format");
1091    let object_format = match de::string(&format_path, obj.take("object_format")?)?.as_str() {
1092        "sha1" => ObjectFormat::Sha1,
1093        "sha256" => ObjectFormat::Sha256,
1094        _ => return fail(&format_path, ErrorKind::InvalidValue),
1095    };
1096    let tree_oid = de::string(&obj.field("tree_oid"), obj.take("tree_oid")?)?;
1097    obj.finish()?;
1098    TreeIdentity::new(object_format, tree_oid)
1099        .ok_or_else(|| Error::new(path, ErrorKind::InvalidValue))
1100}
1101
1102fn decode_reason(path: &str, value: Value) -> Result<String, Error> {
1103    let raw = de::string(path, value)?;
1104    let length = raw.chars().count();
1105    if (1..=1024).contains(&length) && raw.chars().any(|c| !c.is_whitespace()) {
1106        Ok(raw)
1107    } else {
1108        fail(path, ErrorKind::InvalidValue)
1109    }
1110}
1111
1112fn decode_intent(path: &str, value: Value) -> Result<TargetIntent, Error> {
1113    let mut obj = Obj::new(path, value)?;
1114    de::const_str(&obj.field("kind"), obj.take("kind")?, "repository-path")?;
1115    let target_path = decode_repo_path(&obj.field("path"), obj.take("path")?)?;
1116    let target_kind = TargetKind::decode(&obj.field("target_kind"), obj.take("target_kind")?)?;
1117    let query_digest =
1118        decode_nullable_digest(&obj.field("query_digest"), obj.take("query_digest")?)?;
1119    let fragment_digest =
1120        decode_nullable_digest(&obj.field("fragment_digest"), obj.take("fragment_digest")?)?;
1121    obj.finish()?;
1122    Ok(TargetIntent {
1123        path: target_path,
1124        target_kind,
1125        query_digest,
1126        fragment_digest,
1127    })
1128}
1129
1130fn decode_scope(path: &str, value: Value) -> Result<FindingScope, Error> {
1131    let mut obj = Obj::new(path, value)?;
1132    de::const_str(&obj.field("kind"), obj.take("kind")?, "reference")?;
1133    let document = decode_repo_path(&obj.field("document"), obj.take("document")?)?;
1134    let source_construct = SourceConstruct::decode(
1135        &obj.field("source_construct"),
1136        obj.take("source_construct")?,
1137    )?;
1138    let normalized_target_intent = decode_intent(
1139        &obj.field("normalized_target_intent"),
1140        obj.take("normalized_target_intent")?,
1141    )?;
1142    let occurrence_path = obj.field("occurrence");
1143    let mut occurrence = Obj::new(&occurrence_path, obj.take("occurrence")?)?;
1144    de::const_str(
1145        &occurrence.field("kind"),
1146        occurrence.take("kind")?,
1147        "source-projection",
1148    )?;
1149    let source_projection_digest = decode_digest(
1150        &occurrence.field("source_projection_digest"),
1151        occurrence.take("source_projection_digest")?,
1152    )?;
1153    occurrence.finish()?;
1154    obj.finish()?;
1155    Ok(FindingScope {
1156        document,
1157        source_construct,
1158        normalized_target_intent,
1159        source_projection_digest,
1160    })
1161}
1162
1163fn decode_key_input(path: &str, value: Value) -> Result<(FindingKeyInput, Digest), Error> {
1164    let digest = hj(FINDING_KEY_DOMAIN, &value);
1165    let mut obj = Obj::new(path, value)?;
1166    de::const_str(
1167        &obj.field("schema"),
1168        obj.take("schema")?,
1169        FINDING_KEY_INPUT_SCHEMA,
1170    )?;
1171    let finding_kind =
1172        EligibleFindingKind::decode(&obj.field("finding_kind"), obj.take("finding_kind")?)?;
1173    let scope = decode_scope(&obj.field("scope"), obj.take("scope")?)?;
1174    obj.finish()?;
1175    Ok((
1176        FindingKeyInput {
1177            finding_kind,
1178            scope,
1179        },
1180        digest,
1181    ))
1182}
1183
1184fn decode_resolution(path: &str, value: Value) -> Result<Resolution<RepoPathText>, Error> {
1185    let mut obj = Obj::new(path, value)?;
1186    let kind_path = obj.field("kind");
1187    let kind_text = de::string(&kind_path, obj.take("kind")?)?;
1188    let Ok(kind) = kind_text.parse::<ResolutionTag>() else {
1189        return fail(&kind_path, ErrorKind::InvalidValue);
1190    };
1191    match kind {
1192        ResolutionTag::Missing => {
1193            let reason_path = obj.field("reason");
1194            let reason_text = de::string(&reason_path, obj.take("reason")?)?;
1195            let Ok(reason) = reason_text.parse::<MissingTag>() else {
1196                return fail(&reason_path, ErrorKind::InvalidValue);
1197            };
1198            let resolved_path = decode_repo_path(&obj.field("path"), obj.take("path")?)?;
1199            obj.finish()?;
1200            Ok(Resolution::Missing(match reason {
1201                MissingTag::PathNotFound => Missing::PathNotFound {
1202                    path: resolved_path,
1203                },
1204                MissingTag::LineFragmentOutOfRange => Missing::LineFragmentOutOfRange {
1205                    path: resolved_path,
1206                },
1207            }))
1208        }
1209        ResolutionTag::TypeMismatch => {
1210            let target = decode_resolution_target(&obj.field("target"), obj.take("target")?)?;
1211            obj.finish()?;
1212            Ok(Resolution::TypeMismatch(target))
1213        }
1214        ResolutionTag::Resolved
1215        | ResolutionTag::UnsupportedTarget
1216        | ResolutionTag::UnsupportedSemantics
1217        | ResolutionTag::UnsupportedVersion
1218        | ResolutionTag::Invalid
1219        | ResolutionTag::External => fail(&kind_path, ErrorKind::InvalidValue),
1220    }
1221}
1222
1223fn decode_resolution_target(path: &str, value: Value) -> Result<Target<RepoPathText>, Error> {
1224    let mut obj = Obj::new(path, value)?;
1225    let kind_path = obj.field("kind");
1226    let kind_text = de::string(&kind_path, obj.take("kind")?)?;
1227    let Ok(kind) = kind_text.parse::<TargetTag>() else {
1228        return fail(&kind_path, ErrorKind::InvalidValue);
1229    };
1230    let resolved_path = decode_repo_path(&obj.field("path"), obj.take("path")?)?;
1231    match kind {
1232        TargetTag::Tree => {
1233            obj.finish()?;
1234            Ok(Target::Tree {
1235                path: resolved_path,
1236            })
1237        }
1238        TargetTag::Blob => {
1239            let mode_path = obj.field("mode");
1240            let mode_text = de::string(&mode_path, obj.take("mode")?)?;
1241            let Ok(mode) = mode_text.parse::<BlobMode>() else {
1242                return fail(&mode_path, ErrorKind::InvalidValue);
1243            };
1244            let content = decode_resolution_content(&obj.field("content"), obj.take("content")?)?;
1245            obj.finish()?;
1246            Ok(Target::Blob(BlobTarget {
1247                path: resolved_path,
1248                mode,
1249                content,
1250            }))
1251        }
1252    }
1253}
1254
1255fn decode_resolution_content(path: &str, value: Value) -> Result<BlobContent, Error> {
1256    let mut obj = Obj::new(path, value)?;
1257    let kind_path = obj.field("kind");
1258    let kind_text = de::string(&kind_path, obj.take("kind")?)?;
1259    let Ok(kind) = kind_text.parse::<BlobContentTag>() else {
1260        return fail(&kind_path, ErrorKind::InvalidValue);
1261    };
1262    let raw_digest = decode_digest(&obj.field("raw_digest"), obj.take("raw_digest")?)?;
1263    match kind {
1264        BlobContentTag::Available => {
1265            let projection_digest = decode_digest(
1266                &obj.field("projection_digest"),
1267                obj.take("projection_digest")?,
1268            )?;
1269            obj.finish()?;
1270            Ok(BlobContent::Available {
1271                raw_digest,
1272                projection_digest,
1273            })
1274        }
1275        BlobContentTag::LfsPointer => {
1276            obj.finish()?;
1277            Ok(BlobContent::LfsPointer { raw_digest })
1278        }
1279    }
1280}
1281
1282struct DecodedFact {
1283    fact: Fact,
1284    fact_digest: Digest,
1285    finding_key: Digest,
1286}
1287
1288fn decode_fact(path: &str, value: Value) -> Result<DecodedFact, Error> {
1289    let fact_digest = hj(FACT_DOMAIN, &value);
1290    let mut obj = Obj::new(path, value)?;
1291    de::const_str(&obj.field("schema"), obj.take("schema")?, FACT_SCHEMA)?;
1292    let finding_kind =
1293        EligibleFindingKind::decode(&obj.field("finding_kind"), obj.take("finding_kind")?)?;
1294    let key_path = obj.field("key_input");
1295    let (key_input, finding_key) = decode_key_input(&key_path, obj.take("key_input")?)?;
1296    let evidence_path = obj.field("evidence");
1297    let mut evidence = Obj::new(&evidence_path, obj.take("evidence")?)?;
1298    de::const_str(&evidence.field("kind"), evidence.take("kind")?, "reference")?;
1299    let resolution =
1300        decode_resolution(&evidence.field("resolution"), evidence.take("resolution")?)?;
1301    let multiplicity_path = evidence.field("occurrence_multiplicity");
1302    if de::integer(
1303        &multiplicity_path,
1304        evidence.take("occurrence_multiplicity")?,
1305    )? != 1
1306    {
1307        return fail(&multiplicity_path, ErrorKind::InvalidValue);
1308    }
1309    evidence.finish()?;
1310    obj.finish()?;
1311
1312    let Some(fact) = Fact::new(key_input, resolution) else {
1313        return fail(path, ErrorKind::Inconsistent);
1314    };
1315    if fact.finding_kind() != finding_kind {
1316        return fail(path, ErrorKind::Inconsistent);
1317    }
1318    Ok(DecodedFact {
1319        fact,
1320        fact_digest,
1321        finding_key,
1322    })
1323}
1324
1325struct ItemCore {
1326    finding_key: Digest,
1327    fact: Fact,
1328    fact_digest: Digest,
1329    owner: OwnerId,
1330    reason: String,
1331    created_at: UtcInstant,
1332    expires_at: UtcInstant,
1333}
1334
1335fn decode_item_core(obj: &mut Obj, fact_field: &str) -> Result<ItemCore, Error> {
1336    let finding_key_path = obj.field("finding_key");
1337    let finding_key = decode_digest(&finding_key_path, obj.take("finding_key")?)?;
1338    let fact_path = obj.field(fact_field);
1339    let decoded_fact = decode_fact(&fact_path, obj.take(fact_field)?)?;
1340    if finding_key != decoded_fact.finding_key {
1341        return fail(&finding_key_path, ErrorKind::DigestMismatch);
1342    }
1343    let fact_digest_field = format!("{fact_field}_digest");
1344    let fact_digest_path = obj.field(&fact_digest_field);
1345    let fact_digest = decode_digest(&fact_digest_path, obj.take(&fact_digest_field)?)?;
1346    if fact_digest != decoded_fact.fact_digest {
1347        return fail(&fact_digest_path, ErrorKind::DigestMismatch);
1348    }
1349    let owner = decode_owner(&obj.field("owner"), obj.take("owner")?)?;
1350    let reason = decode_reason(&obj.field("reason"), obj.take("reason")?)?;
1351    let created_at = decode_instant(&obj.field("created_at"), obj.take("created_at")?)?;
1352    let expires_at = decode_instant(&obj.field("expires_at"), obj.take("expires_at")?)?;
1353    Ok(ItemCore {
1354        finding_key,
1355        fact: decoded_fact.fact,
1356        fact_digest,
1357        owner,
1358        reason,
1359        created_at,
1360        expires_at,
1361    })
1362}
1363
1364fn decode_debt_item(path: &str, value: Value) -> Result<DebtItem, Error> {
1365    let mut obj = Obj::new(path, value)?;
1366    let debt_id = decode_artifact_id(&obj.field("debt_id"), obj.take("debt_id")?)?;
1367    let core = decode_item_core(&mut obj, "accepted_fact")?;
1368    obj.finish()?;
1369    if core.created_at >= core.expires_at {
1370        return fail(path, ErrorKind::Inconsistent);
1371    }
1372    Ok(DebtItem {
1373        debt_id,
1374        finding_key: core.finding_key,
1375        accepted_fact: core.fact,
1376        accepted_fact_digest: core.fact_digest,
1377        owner: core.owner,
1378        reason: core.reason,
1379        created_at: core.created_at,
1380        expires_at: core.expires_at,
1381    })
1382}
1383
1384fn decode_waiver_item(path: &str, value: Value) -> Result<WaiverItem, Error> {
1385    let mut obj = Obj::new(path, value)?;
1386    let waiver_id = decode_artifact_id(&obj.field("waiver_id"), obj.take("waiver_id")?)?;
1387    let core = decode_item_core(&mut obj, "authorized_fact")?;
1388    let candidate_tree = decode_tree(&obj.field("candidate_tree"), obj.take("candidate_tree")?)?;
1389    let issuer = decode_owner(&obj.field("issuer"), obj.take("issuer")?)?;
1390    let not_before = decode_instant(&obj.field("not_before"), obj.take("not_before")?)?;
1391    de::const_str(
1392        &obj.field("residual_disposition"),
1393        obj.take("residual_disposition")?,
1394        "warn",
1395    )?;
1396    obj.finish()?;
1397    if core.created_at > not_before || not_before >= core.expires_at {
1398        return fail(path, ErrorKind::Inconsistent);
1399    }
1400    Ok(WaiverItem {
1401        waiver_id,
1402        finding_key: core.finding_key,
1403        authorized_fact: core.fact,
1404        authorized_fact_digest: core.fact_digest,
1405        candidate_tree,
1406        owner: core.owner,
1407        issuer,
1408        reason: core.reason,
1409        created_at: core.created_at,
1410        not_before,
1411        expires_at: core.expires_at,
1412    })
1413}