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    ReferencesPerDocument,
357    ReferencesPerSnapshot,
358    OrganizationPolicyEntries,
359    CompleteFindings,
360    TypedAnalysisErrorsRetained,
361    MachineJsonBytes,
362    PrivateTemporaryStorageBytes,
363    EvaluatorManagedMemoryBytes,
364}
365
366impl ResourceName {
367    /// Every resource name in wire-contract order.
368    #[must_use]
369    pub fn all() -> impl ExactSizeIterator<Item = Self> {
370        Self::iter()
371    }
372
373    /// The phase a resource crossing reports, from the closed partition.
374    #[must_use]
375    pub const fn phase(self) -> &'static str {
376        match self {
377            Self::ControlInputBytes
378            | Self::RepositoryPolicyEntries
379            | Self::DebtItems
380            | Self::WaiverItems
381            | Self::OrganizationPolicyEntries => "configuration",
382            Self::GitObjectBytes
383            | Self::GitCompressedObjectBytes
384            | Self::AggregateGitCompressedObjectBytesPerEvaluation
385            | Self::GitPackDirectoryEntries
386            | Self::GitPackFiles
387            | Self::GitPackIndexBytes
388            | Self::AggregateGitPackIndexBytes
389            | Self::GitDeltaDepth
390            | Self::GitIndexBytes
391            | Self::GitTreeEntriesPerSnapshot
392            | Self::RawPathBytes => "git",
393            Self::DocumentsPerSnapshot
394            | Self::DocumentBlobBytes
395            | Self::AggregateDocumentBytesPerSnapshot
396            | Self::SelectedControlBlobBytes
397            | Self::AggregateSelectedControlBytesPerSnapshot => "discovery",
398            Self::RawLinkDestinationBytes
399            | Self::ParserNesting
400            | Self::ParserNodesPerDocument
401            | Self::ParserNodesPerSnapshot
402            | Self::ReferencesPerDocument
403            | Self::ReferencesPerSnapshot => "parse",
404            Self::ReferencedTargetBlobBytes
405            | Self::AggregateReferencedTargetBytesPerSnapshot
406            | Self::AggregateLineFragmentEvaluationBytesPerSnapshot => "resolution",
407            Self::CompleteFindings => "policy",
408            Self::MachineJsonBytes => "output",
409            Self::TypedAnalysisErrorsRetained
410            | Self::PrivateTemporaryStorageBytes
411            | Self::EvaluatorManagedMemoryBytes => "internal",
412        }
413    }
414
415    #[must_use]
416    pub fn as_str(self) -> &'static str {
417        self.into()
418    }
419
420    fn decode(path: &str, value: Value) -> Result<Self, Error> {
421        let raw = de::string(path, value)?;
422        let Ok(resource) = raw.parse() else {
423            return fail(path, ErrorKind::InvalidValue);
424        };
425        Ok(resource)
426    }
427}
428
429#[derive(Clone, Debug, PartialEq, Eq)]
430pub struct DocumentInclude {
431    pub path: RepoPathText,
432    pub kind: IncludeKind,
433}
434
435#[derive(Clone, Debug, PartialEq, Eq)]
436pub struct FindingDisposition {
437    pub finding_kind: PromotableFindingKind,
438    pub disposition: Disposition,
439}
440
441#[derive(Clone, Debug, PartialEq, Eq)]
442pub struct ScannerPolicy {
443    pub digest: Digest,
444    pub document_includes: Vec<DocumentInclude>,
445    pub protected_inventory: Vec<RepoPathText>,
446    pub finding_dispositions: Vec<FindingDisposition>,
447}
448
449impl ScannerPolicy {
450    /// # Errors
451    ///
452    /// Fails on strict-JSON defects, schema-shape violations, unknown fields,
453    /// invalid grammar values, and unsorted or duplicate set members.
454    pub fn parse(bytes: &[u8]) -> Result<Self, Error> {
455        let value = root(bytes)?;
456        let digest = hj(SCANNER_POLICY_SCHEMA, &value);
457        let mut obj = Obj::new("$", value)?;
458        de::const_str(
459            &obj.field("schema"),
460            obj.take("schema")?,
461            SCANNER_POLICY_SCHEMA,
462        )?;
463
464        let includes_path = obj.field("document_includes");
465        let includes = de::array(&includes_path, obj.take("document_includes")?)?;
466        let document_includes = decode_items(&includes_path, includes, 100_000, decode_include)?;
467        sorted_set(&includes_path, &document_includes, |a, b| {
468            (a.path.as_str(), a.kind).cmp(&(b.path.as_str(), b.kind))
469        })?;
470
471        let inventory_path = obj.field("protected_inventory");
472        let protected_inventory =
473            decode_path_set(&inventory_path, obj.take("protected_inventory")?)?;
474
475        let dispositions_path = obj.field("finding_dispositions");
476        let raw = de::array(&dispositions_path, obj.take("finding_dispositions")?)?;
477        let finding_dispositions =
478            decode_items(&dispositions_path, raw, 3, decode_disposition_rule)?;
479        sorted_set(&dispositions_path, &finding_dispositions, |a, b| {
480            a.finding_kind.as_str().cmp(b.finding_kind.as_str())
481        })?;
482
483        obj.finish()?;
484        Ok(Self {
485            digest,
486            document_includes,
487            protected_inventory,
488            finding_dispositions,
489        })
490    }
491}
492
493#[derive(Clone, Debug, PartialEq, Eq)]
494pub struct ResourceLimit {
495    pub resource: ResourceName,
496    pub maximum: i64,
497}
498
499#[derive(Clone, Debug, PartialEq, Eq)]
500pub struct FloorDisposition {
501    pub finding_kind: PromotableFindingKind,
502    pub disposition: Disposition,
503}
504
505#[derive(Clone, Debug, PartialEq, Eq)]
506pub struct OrganizationFloor {
507    pub digest: Digest,
508    pub floor_id: ArtifactId,
509    pub repository: RepositoryIdentity,
510    pub ref_name: BranchRef,
511    pub minimum_profile: Profile,
512    pub minimum_dispositions: Vec<FindingDisposition>,
513    pub protected_inventory: Vec<RepoPathText>,
514    pub protected_control_paths: Vec<RepoPathText>,
515    pub waivable_finding_kinds: Vec<EligibleFindingKind>,
516    pub authorized_debt_owners: Vec<OwnerId>,
517    pub authorized_waiver_issuers: Vec<OwnerId>,
518    pub resource_limits: Vec<ResourceLimit>,
519}
520
521/// A floor rejection: a schema-layer defect, or the combined
522/// `organization-policy-entries` count crossing its effective limit.
523#[derive(Clone, Debug, PartialEq, Eq)]
524pub enum FloorDefect {
525    Schema(Error),
526    Entries {
527        configured_limit: u64,
528        observed_lower_bound: u64,
529    },
530}
531
532impl From<Error> for FloorDefect {
533    fn from(error: Error) -> Self {
534        Self::Schema(error)
535    }
536}
537
538pub const ORGANIZATION_POLICY_ENTRIES_LIMIT: u64 = 100_000;
539
540impl OrganizationFloor {
541    #[must_use]
542    pub const fn schema(&self) -> &'static str {
543        ORGANIZATION_FLOOR_SCHEMA
544    }
545
546    /// # Errors
547    ///
548    /// Fails on strict-JSON defects, schema-shape violations, unknown fields,
549    /// invalid grammar values, per-resource bound violations, unsorted or
550    /// duplicate set members, and a combined entry count over the built-in
551    /// `organization-policy-entries` limit or a tighter self-declared one.
552    pub fn parse(bytes: &[u8]) -> Result<Self, FloorDefect> {
553        let value = root(bytes)?;
554        let digest = hj(ORGANIZATION_FLOOR_SCHEMA, &value);
555        let mut obj = Obj::new("$", value)?;
556        de::const_str(
557            &obj.field("schema"),
558            obj.take("schema")?,
559            ORGANIZATION_FLOOR_SCHEMA,
560        )?;
561
562        let floor_id = decode_artifact_id(&obj.field("floor_id"), obj.take("floor_id")?)?;
563        let repository = decode_repository(&obj.field("repository"), obj.take("repository")?)?;
564        let ref_name = decode_branch_ref(&obj.field("ref"), obj.take("ref")?)?;
565        let minimum_profile =
566            Profile::decode(&obj.field("minimum_profile"), obj.take("minimum_profile")?)?;
567
568        let dispositions_path = obj.field("minimum_dispositions");
569        let dispositions_raw = de::array(&dispositions_path, obj.take("minimum_dispositions")?)?;
570        let inventory_path = obj.field("protected_inventory");
571        let inventory_raw = de::array(&inventory_path, obj.take("protected_inventory")?)?;
572        let control_paths_path = obj.field("protected_control_paths");
573        let control_paths_raw =
574            de::array(&control_paths_path, obj.take("protected_control_paths")?)?;
575        let waivable_path = obj.field("waivable_finding_kinds");
576        let waivable_raw = de::array(&waivable_path, obj.take("waivable_finding_kinds")?)?;
577        let owners_path = obj.field("authorized_debt_owners");
578        let owners_raw = de::array(&owners_path, obj.take("authorized_debt_owners")?)?;
579        let issuers_path = obj.field("authorized_waiver_issuers");
580        let issuers_raw = de::array(&issuers_path, obj.take("authorized_waiver_issuers")?)?;
581        let limits_path = obj.field("resource_limits");
582        let limits_raw = de::array(&limits_path, obj.take("resource_limits")?)?;
583
584        let combined = [
585            dispositions_raw.len(),
586            inventory_raw.len(),
587            control_paths_raw.len(),
588            waivable_raw.len(),
589            owners_raw.len(),
590            issuers_raw.len(),
591            limits_raw.len(),
592        ]
593        .iter()
594        .map(|&len| u64::try_from(len).unwrap_or(u64::MAX))
595        .fold(0_u64, u64::saturating_add);
596        if combined > ORGANIZATION_POLICY_ENTRIES_LIMIT {
597            return Err(FloorDefect::Entries {
598                configured_limit: ORGANIZATION_POLICY_ENTRIES_LIMIT,
599                observed_lower_bound: ORGANIZATION_POLICY_ENTRIES_LIMIT.saturating_add(1),
600            });
601        }
602
603        let minimum_dispositions = decode_items(
604            &dispositions_path,
605            dispositions_raw,
606            3,
607            decode_disposition_rule,
608        )?;
609        sorted_set(&dispositions_path, &minimum_dispositions, |a, b| {
610            a.finding_kind.as_str().cmp(b.finding_kind.as_str())
611        })?;
612        let protected_inventory = decode_path_items(&inventory_path, inventory_raw)?;
613        let protected_control_paths = decode_path_items(&control_paths_path, control_paths_raw)?;
614        let waivable_finding_kinds =
615            decode_items(&waivable_path, waivable_raw, 2, |path, value| {
616                EligibleFindingKind::decode(path, value)
617            })?;
618        sorted_set(&waivable_path, &waivable_finding_kinds, |a, b| {
619            a.as_str().cmp(b.as_str())
620        })?;
621        let authorized_debt_owners = decode_owner_items(&owners_path, owners_raw)?;
622        let authorized_waiver_issuers = decode_owner_items(&issuers_path, issuers_raw)?;
623        let cap = ResourceName::all().len();
624        let resource_limits = decode_items(&limits_path, limits_raw, cap, decode_resource_limit)?;
625        sorted_set(&limits_path, &resource_limits, |a, b| {
626            a.resource.as_str().cmp(b.resource.as_str())
627        })?;
628
629        obj.finish()?;
630        if let Some(declared) = resource_limits
631            .iter()
632            .find(|row| row.resource == ResourceName::OrganizationPolicyEntries)
633        {
634            let declared = u64::try_from(declared.maximum).unwrap_or(u64::MAX);
635            if combined > declared {
636                return Err(FloorDefect::Entries {
637                    configured_limit: declared,
638                    observed_lower_bound: declared.saturating_add(1),
639                });
640            }
641        }
642        Ok(Self {
643            digest,
644            floor_id,
645            repository,
646            ref_name,
647            minimum_profile,
648            minimum_dispositions,
649            protected_inventory,
650            protected_control_paths,
651            waivable_finding_kinds,
652            authorized_debt_owners,
653            authorized_waiver_issuers,
654            resource_limits,
655        })
656    }
657}
658
659#[derive(Clone, Debug, PartialEq, Eq)]
660pub struct TargetIntent {
661    pub path: RepoPathText,
662    pub target_kind: TargetKind,
663    pub query_digest: Option<Digest>,
664    pub fragment_digest: Option<Digest>,
665}
666
667#[derive(Clone, Debug, PartialEq, Eq)]
668pub struct FindingScope {
669    pub document: RepoPathText,
670    pub source_construct: SourceConstruct,
671    pub normalized_target_intent: TargetIntent,
672    pub source_projection_digest: Digest,
673}
674
675#[derive(Clone, Debug, PartialEq, Eq)]
676pub struct FindingKeyInput {
677    pub finding_kind: EligibleFindingKind,
678    pub scope: FindingScope,
679}
680
681#[derive(Clone, Debug, PartialEq, Eq)]
682pub struct Fact {
683    key_input: FindingKeyInput,
684    resolution: Resolution<RepoPathText>,
685}
686
687impl Fact {
688    /// Builds a structural fact only when the key kind and resolution family agree.
689    /// Non-structural resolution families are not eligible for control items.
690    #[must_use]
691    pub fn new(key_input: FindingKeyInput, resolution: Resolution<RepoPathText>) -> Option<Self> {
692        let expected = match &resolution {
693            Resolution::Missing(_) => EligibleFindingKind::ExplicitTargetMissing,
694            Resolution::TypeMismatch(_) => EligibleFindingKind::ExplicitTargetTypeMismatch,
695            Resolution::Resolved(_)
696            | Resolution::UnsupportedTarget(_)
697            | Resolution::UnsupportedSemantics(_)
698            | Resolution::UnsupportedVersion(_)
699            | Resolution::Invalid(_)
700            | Resolution::External(_) => return None,
701        };
702        (key_input.finding_kind == expected).then_some(Self {
703            key_input,
704            resolution,
705        })
706    }
707
708    /// The finding kind fixed by the validated key and resolution family.
709    #[must_use]
710    pub const fn finding_kind(&self) -> EligibleFindingKind {
711        self.key_input.finding_kind
712    }
713
714    /// The canonical finding-key preimage embedded in this fact.
715    #[must_use]
716    pub const fn key_input(&self) -> &FindingKeyInput {
717        &self.key_input
718    }
719
720    /// The structural resolution evidence embedded in this fact.
721    #[must_use]
722    pub const fn resolution(&self) -> &Resolution<RepoPathText> {
723        &self.resolution
724    }
725}
726
727#[derive(Clone, Debug, PartialEq, Eq)]
728pub struct DebtItem {
729    pub debt_id: ArtifactId,
730    pub finding_key: Digest,
731    pub accepted_fact: Fact,
732    pub accepted_fact_digest: Digest,
733    pub owner: OwnerId,
734    pub reason: String,
735    pub created_at: UtcInstant,
736    pub expires_at: UtcInstant,
737}
738
739#[derive(Clone, Debug, PartialEq, Eq)]
740pub struct DebtSnapshot {
741    pub digest: Digest,
742    pub repository: RepositoryIdentity,
743    pub ref_name: BranchRef,
744    pub organization_floor_digest: Digest,
745    pub adoption_tree: TreeIdentity,
746    pub adoption_report_payload_digest: Digest,
747    pub created_at: UtcInstant,
748    pub items: Vec<DebtItem>,
749}
750
751impl DebtSnapshot {
752    #[must_use]
753    pub const fn schema(&self) -> &'static str {
754        DEBT_SNAPSHOT_SCHEMA
755    }
756
757    /// # Errors
758    ///
759    /// Fails on strict-JSON defects, schema-shape violations, embedded key or
760    /// fact digests that do not recompute, fact-kind/resolution inconsistencies,
761    /// causal time-order violations, and unsorted or duplicate items or keys.
762    pub fn parse(bytes: &[u8]) -> Result<Self, Error> {
763        let value = root(bytes)?;
764        let digest = hj(DEBT_SNAPSHOT_SCHEMA, &value);
765        let mut obj = Obj::new("$", value)?;
766        de::const_str(
767            &obj.field("schema"),
768            obj.take("schema")?,
769            DEBT_SNAPSHOT_SCHEMA,
770        )?;
771
772        let repository = decode_repository(&obj.field("repository"), obj.take("repository")?)?;
773        let ref_name = decode_branch_ref(&obj.field("ref"), obj.take("ref")?)?;
774        let organization_floor_digest = decode_digest(
775            &obj.field("organization_floor_digest"),
776            obj.take("organization_floor_digest")?,
777        )?;
778        let adoption_tree = decode_tree(&obj.field("adoption_tree"), obj.take("adoption_tree")?)?;
779        let adoption_report_payload_digest = decode_digest(
780            &obj.field("adoption_report_payload_digest"),
781            obj.take("adoption_report_payload_digest")?,
782        )?;
783        let created_at = decode_instant(&obj.field("created_at"), obj.take("created_at")?)?;
784
785        let items_path = obj.field("items");
786        let raw = de::array(&items_path, obj.take("items")?)?;
787        let items = decode_items(&items_path, raw, 100_000, decode_debt_item)?;
788        sorted_set(&items_path, &items, |a, b| {
789            a.debt_id.as_str().cmp(b.debt_id.as_str())
790        })?;
791        let mut keys: BTreeSet<Digest> = BTreeSet::new();
792        for item in &items {
793            if !keys.insert(item.finding_key) {
794                return fail(&items_path, ErrorKind::DuplicateMember);
795            }
796            if item.created_at > created_at {
797                return fail(&items_path, ErrorKind::Inconsistent);
798            }
799        }
800
801        obj.finish()?;
802        Ok(Self {
803            digest,
804            repository,
805            ref_name,
806            organization_floor_digest,
807            adoption_tree,
808            adoption_report_payload_digest,
809            created_at,
810            items,
811        })
812    }
813}
814
815#[derive(Clone, Debug, PartialEq, Eq)]
816pub struct WaiverItem {
817    pub waiver_id: ArtifactId,
818    pub finding_key: Digest,
819    pub authorized_fact: Fact,
820    pub authorized_fact_digest: Digest,
821    pub candidate_tree: TreeIdentity,
822    pub owner: OwnerId,
823    pub issuer: OwnerId,
824    pub reason: String,
825    pub created_at: UtcInstant,
826    pub not_before: UtcInstant,
827    pub expires_at: UtcInstant,
828}
829
830#[derive(Clone, Debug, PartialEq, Eq)]
831pub struct WaiverBundle {
832    pub digest: Digest,
833    pub repository: RepositoryIdentity,
834    pub ref_name: BranchRef,
835    pub organization_floor_digest: Digest,
836    pub created_at: UtcInstant,
837    pub items: Vec<WaiverItem>,
838}
839
840impl WaiverBundle {
841    #[must_use]
842    pub const fn schema(&self) -> &'static str {
843        WAIVER_BUNDLE_SCHEMA
844    }
845
846    /// # Errors
847    ///
848    /// Fails on strict-JSON defects, schema-shape violations, embedded key or
849    /// fact digests that do not recompute, fact-kind/resolution inconsistencies,
850    /// causal time-order violations, duplicate waiver IDs, and duplicate
851    /// `(candidate_tree, finding_key)` pairs.
852    pub fn parse(bytes: &[u8]) -> Result<Self, Error> {
853        let value = root(bytes)?;
854        let digest = hj(WAIVER_BUNDLE_SCHEMA, &value);
855        let mut obj = Obj::new("$", value)?;
856        de::const_str(
857            &obj.field("schema"),
858            obj.take("schema")?,
859            WAIVER_BUNDLE_SCHEMA,
860        )?;
861
862        let repository = decode_repository(&obj.field("repository"), obj.take("repository")?)?;
863        let ref_name = decode_branch_ref(&obj.field("ref"), obj.take("ref")?)?;
864        let organization_floor_digest = decode_digest(
865            &obj.field("organization_floor_digest"),
866            obj.take("organization_floor_digest")?,
867        )?;
868        let created_at = decode_instant(&obj.field("created_at"), obj.take("created_at")?)?;
869
870        let items_path = obj.field("items");
871        let raw = de::array(&items_path, obj.take("items")?)?;
872        let items = decode_items(&items_path, raw, 100_000, decode_waiver_item)?;
873        sorted_set(&items_path, &items, |a, b| {
874            waiver_sort_key(a).cmp(&waiver_sort_key(b))
875        })?;
876        for pair in items.windows(2) {
877            if let [left, right] = pair
878                && left.candidate_tree == right.candidate_tree
879                && left.finding_key == right.finding_key
880            {
881                return fail(&items_path, ErrorKind::DuplicateMember);
882            }
883        }
884        let mut ids: BTreeSet<&str> = BTreeSet::new();
885        for item in &items {
886            if !ids.insert(item.waiver_id.as_str()) {
887                return fail(&items_path, ErrorKind::DuplicateMember);
888            }
889            if item.created_at > created_at {
890                return fail(&items_path, ErrorKind::Inconsistent);
891            }
892        }
893
894        obj.finish()?;
895        Ok(Self {
896            digest,
897            repository,
898            ref_name,
899            organization_floor_digest,
900            created_at,
901            items,
902        })
903    }
904}
905
906fn waiver_sort_key(item: &WaiverItem) -> (ObjectFormat, &str, Digest, &str) {
907    (
908        item.candidate_tree.object_format,
909        item.candidate_tree.tree_oid.as_str(),
910        item.finding_key,
911        item.waiver_id.as_str(),
912    )
913}
914
915/// The one restricted-JSON root every control document parses through.
916///
917/// # Errors
918///
919/// Any strict-JSON defect, carried as `ErrorKind::Json`.
920pub fn root(bytes: &[u8]) -> Result<Value, Error> {
921    json::parse(bytes).map_err(|defect| Error::new("$", ErrorKind::Json(defect)))
922}
923
924fn decode_items<T>(
925    path: &str,
926    raw: Vec<Value>,
927    limit: usize,
928    decode: impl Fn(&str, Value) -> Result<T, Error>,
929) -> Result<Vec<T>, Error> {
930    if raw.len() > limit {
931        return fail(path, ErrorKind::LimitExceeded);
932    }
933    raw.into_iter()
934        .enumerate()
935        .map(|(index, value)| decode(&format!("{path}[{index}]"), value))
936        .collect()
937}
938
939fn sorted_set<T>(
940    path: &str,
941    items: &[T],
942    compare: impl Fn(&T, &T) -> Ordering,
943) -> Result<(), Error> {
944    for pair in items.windows(2) {
945        if let [left, right] = pair {
946            match compare(left, right) {
947                Ordering::Less => {}
948                Ordering::Equal => return fail(path, ErrorKind::DuplicateMember),
949                Ordering::Greater => return fail(path, ErrorKind::UnsortedSet),
950            }
951        }
952    }
953    Ok(())
954}
955
956fn decode_include(path: &str, value: Value) -> Result<DocumentInclude, Error> {
957    let mut obj = Obj::new(path, value)?;
958    let include_path = decode_repo_path(&obj.field("path"), obj.take("path")?)?;
959    let kind = IncludeKind::decode(&obj.field("kind"), obj.take("kind")?)?;
960    obj.finish()?;
961    Ok(DocumentInclude {
962        path: include_path,
963        kind,
964    })
965}
966
967fn decode_disposition_rule(path: &str, value: Value) -> Result<FindingDisposition, Error> {
968    let mut obj = Obj::new(path, value)?;
969    let finding_kind =
970        PromotableFindingKind::decode(&obj.field("finding_kind"), obj.take("finding_kind")?)?;
971    let disposition = Disposition::decode(&obj.field("disposition"), obj.take("disposition")?)?;
972    obj.finish()?;
973    Ok(FindingDisposition {
974        finding_kind,
975        disposition,
976    })
977}
978
979fn decode_resource_limit(path: &str, value: Value) -> Result<ResourceLimit, Error> {
980    let mut obj = Obj::new(path, value)?;
981    let resource = ResourceName::decode(&obj.field("resource"), obj.take("resource")?)?;
982    let maximum_path = obj.field("maximum");
983    let maximum = de::integer(&maximum_path, obj.take("maximum")?)?;
984    obj.finish()?;
985    let in_bounds = if resource == ResourceName::TypedAnalysisErrorsRetained {
986        (1..=64).contains(&maximum)
987    } else if resource == ResourceName::MachineJsonBytes {
988        maximum == 67_108_864
989    } else {
990        maximum >= 0
991    };
992    if in_bounds {
993        Ok(ResourceLimit { resource, maximum })
994    } else {
995        fail(&maximum_path, ErrorKind::InvalidValue)
996    }
997}
998
999fn decode_path_set(path: &str, value: Value) -> Result<Vec<RepoPathText>, Error> {
1000    decode_path_items(path, de::array(path, value)?)
1001}
1002
1003fn decode_path_items(path: &str, raw: Vec<Value>) -> Result<Vec<RepoPathText>, Error> {
1004    let paths = decode_items(path, raw, 100_000, decode_repo_path)?;
1005    sorted_set(path, &paths, |a, b| a.as_str().cmp(b.as_str()))?;
1006    Ok(paths)
1007}
1008
1009fn decode_owner_items(path: &str, raw: Vec<Value>) -> Result<Vec<OwnerId>, Error> {
1010    let owners = decode_items(path, raw, 10_000, decode_owner)?;
1011    sorted_set(path, &owners, |a, b| a.as_str().cmp(b.as_str()))?;
1012    Ok(owners)
1013}
1014
1015fn decode_repo_path(path: &str, value: Value) -> Result<RepoPathText, Error> {
1016    RepoPathText::new(de::string(path, value)?)
1017        .ok_or_else(|| Error::new(path, ErrorKind::InvalidValue))
1018}
1019
1020fn decode_artifact_id(path: &str, value: Value) -> Result<ArtifactId, Error> {
1021    ArtifactId::new(de::string(path, value)?)
1022        .ok_or_else(|| Error::new(path, ErrorKind::InvalidValue))
1023}
1024
1025fn decode_owner(path: &str, value: Value) -> Result<OwnerId, Error> {
1026    OwnerId::new(de::string(path, value)?).ok_or_else(|| Error::new(path, ErrorKind::InvalidValue))
1027}
1028
1029fn decode_branch_ref(path: &str, value: Value) -> Result<BranchRef, Error> {
1030    BranchRef::new(de::string(path, value)?)
1031        .ok_or_else(|| Error::new(path, ErrorKind::InvalidValue))
1032}
1033
1034fn decode_instant(path: &str, value: Value) -> Result<UtcInstant, Error> {
1035    UtcInstant::new(de::string(path, value)?)
1036        .ok_or_else(|| Error::new(path, ErrorKind::InvalidValue))
1037}
1038
1039fn decode_digest(path: &str, value: Value) -> Result<Digest, Error> {
1040    let raw = de::string(path, value)?;
1041    Digest::from_wire(&raw).ok_or_else(|| Error::new(path, ErrorKind::InvalidValue))
1042}
1043
1044fn decode_nullable_digest(path: &str, value: Value) -> Result<Option<Digest>, Error> {
1045    de::nullable(value)
1046        .map(|v| decode_digest(path, v))
1047        .transpose()
1048}
1049
1050pub(crate) fn decode_repository(path: &str, value: Value) -> Result<RepositoryIdentity, Error> {
1051    let mut obj = Obj::new(path, value)?;
1052    let host = de::string(&obj.field("host"), obj.take("host")?)?;
1053    let owner = de::string(&obj.field("owner"), obj.take("owner")?)?;
1054    let name = de::string(&obj.field("name"), obj.take("name")?)?;
1055    obj.finish()?;
1056    RepositoryIdentity::new(host, owner, name)
1057        .ok_or_else(|| Error::new(path, ErrorKind::InvalidValue))
1058}
1059
1060pub(crate) fn decode_provider_run_id(path: &str, value: Value) -> Result<String, Error> {
1061    let raw = de::string(path, value)?;
1062    let bytes = raw.as_bytes();
1063    let allowed = |byte: &u8| {
1064        byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'/' | b'-')
1065    };
1066    if bytes.is_empty()
1067        || bytes.len() > 128
1068        || !bytes.first().is_some_and(u8::is_ascii_alphanumeric)
1069        || !bytes.last().is_some_and(u8::is_ascii_alphanumeric)
1070        || !bytes.iter().all(allowed)
1071    {
1072        return fail(path, ErrorKind::InvalidValue);
1073    }
1074    Ok(raw)
1075}
1076
1077pub(crate) fn decode_provider_id(path: &str, value: Value) -> Result<String, Error> {
1078    let raw = de::string(path, value)?;
1079    if ArtifactId::new(raw.clone()).is_some() {
1080        Ok(raw)
1081    } else {
1082        fail(path, ErrorKind::InvalidValue)
1083    }
1084}
1085
1086fn decode_tree(path: &str, value: Value) -> Result<TreeIdentity, Error> {
1087    let mut obj = Obj::new(path, value)?;
1088    let format_path = obj.field("object_format");
1089    let object_format = match de::string(&format_path, obj.take("object_format")?)?.as_str() {
1090        "sha1" => ObjectFormat::Sha1,
1091        "sha256" => ObjectFormat::Sha256,
1092        _ => return fail(&format_path, ErrorKind::InvalidValue),
1093    };
1094    let tree_oid = de::string(&obj.field("tree_oid"), obj.take("tree_oid")?)?;
1095    obj.finish()?;
1096    TreeIdentity::new(object_format, tree_oid)
1097        .ok_or_else(|| Error::new(path, ErrorKind::InvalidValue))
1098}
1099
1100fn decode_reason(path: &str, value: Value) -> Result<String, Error> {
1101    let raw = de::string(path, value)?;
1102    let length = raw.chars().count();
1103    if (1..=1024).contains(&length) && raw.chars().any(|c| !c.is_whitespace()) {
1104        Ok(raw)
1105    } else {
1106        fail(path, ErrorKind::InvalidValue)
1107    }
1108}
1109
1110fn decode_intent(path: &str, value: Value) -> Result<TargetIntent, Error> {
1111    let mut obj = Obj::new(path, value)?;
1112    de::const_str(&obj.field("kind"), obj.take("kind")?, "repository-path")?;
1113    let target_path = decode_repo_path(&obj.field("path"), obj.take("path")?)?;
1114    let target_kind = TargetKind::decode(&obj.field("target_kind"), obj.take("target_kind")?)?;
1115    let query_digest =
1116        decode_nullable_digest(&obj.field("query_digest"), obj.take("query_digest")?)?;
1117    let fragment_digest =
1118        decode_nullable_digest(&obj.field("fragment_digest"), obj.take("fragment_digest")?)?;
1119    obj.finish()?;
1120    Ok(TargetIntent {
1121        path: target_path,
1122        target_kind,
1123        query_digest,
1124        fragment_digest,
1125    })
1126}
1127
1128fn decode_scope(path: &str, value: Value) -> Result<FindingScope, Error> {
1129    let mut obj = Obj::new(path, value)?;
1130    de::const_str(&obj.field("kind"), obj.take("kind")?, "reference")?;
1131    let document = decode_repo_path(&obj.field("document"), obj.take("document")?)?;
1132    let source_construct = SourceConstruct::decode(
1133        &obj.field("source_construct"),
1134        obj.take("source_construct")?,
1135    )?;
1136    let normalized_target_intent = decode_intent(
1137        &obj.field("normalized_target_intent"),
1138        obj.take("normalized_target_intent")?,
1139    )?;
1140    let occurrence_path = obj.field("occurrence");
1141    let mut occurrence = Obj::new(&occurrence_path, obj.take("occurrence")?)?;
1142    de::const_str(
1143        &occurrence.field("kind"),
1144        occurrence.take("kind")?,
1145        "source-projection",
1146    )?;
1147    let source_projection_digest = decode_digest(
1148        &occurrence.field("source_projection_digest"),
1149        occurrence.take("source_projection_digest")?,
1150    )?;
1151    occurrence.finish()?;
1152    obj.finish()?;
1153    Ok(FindingScope {
1154        document,
1155        source_construct,
1156        normalized_target_intent,
1157        source_projection_digest,
1158    })
1159}
1160
1161fn decode_key_input(path: &str, value: Value) -> Result<(FindingKeyInput, Digest), Error> {
1162    let digest = hj(FINDING_KEY_DOMAIN, &value);
1163    let mut obj = Obj::new(path, value)?;
1164    de::const_str(
1165        &obj.field("schema"),
1166        obj.take("schema")?,
1167        FINDING_KEY_INPUT_SCHEMA,
1168    )?;
1169    let finding_kind =
1170        EligibleFindingKind::decode(&obj.field("finding_kind"), obj.take("finding_kind")?)?;
1171    let scope = decode_scope(&obj.field("scope"), obj.take("scope")?)?;
1172    obj.finish()?;
1173    Ok((
1174        FindingKeyInput {
1175            finding_kind,
1176            scope,
1177        },
1178        digest,
1179    ))
1180}
1181
1182fn decode_resolution(path: &str, value: Value) -> Result<Resolution<RepoPathText>, Error> {
1183    let mut obj = Obj::new(path, value)?;
1184    let kind_path = obj.field("kind");
1185    let kind_text = de::string(&kind_path, obj.take("kind")?)?;
1186    let Ok(kind) = kind_text.parse::<ResolutionTag>() else {
1187        return fail(&kind_path, ErrorKind::InvalidValue);
1188    };
1189    match kind {
1190        ResolutionTag::Missing => {
1191            let reason_path = obj.field("reason");
1192            let reason_text = de::string(&reason_path, obj.take("reason")?)?;
1193            let Ok(reason) = reason_text.parse::<MissingTag>() else {
1194                return fail(&reason_path, ErrorKind::InvalidValue);
1195            };
1196            let resolved_path = decode_repo_path(&obj.field("path"), obj.take("path")?)?;
1197            obj.finish()?;
1198            Ok(Resolution::Missing(match reason {
1199                MissingTag::PathNotFound => Missing::PathNotFound {
1200                    path: resolved_path,
1201                },
1202                MissingTag::LineFragmentOutOfRange => Missing::LineFragmentOutOfRange {
1203                    path: resolved_path,
1204                },
1205            }))
1206        }
1207        ResolutionTag::TypeMismatch => {
1208            let target = decode_resolution_target(&obj.field("target"), obj.take("target")?)?;
1209            obj.finish()?;
1210            Ok(Resolution::TypeMismatch(target))
1211        }
1212        ResolutionTag::Resolved
1213        | ResolutionTag::UnsupportedTarget
1214        | ResolutionTag::UnsupportedSemantics
1215        | ResolutionTag::UnsupportedVersion
1216        | ResolutionTag::Invalid
1217        | ResolutionTag::External => fail(&kind_path, ErrorKind::InvalidValue),
1218    }
1219}
1220
1221fn decode_resolution_target(path: &str, value: Value) -> Result<Target<RepoPathText>, Error> {
1222    let mut obj = Obj::new(path, value)?;
1223    let kind_path = obj.field("kind");
1224    let kind_text = de::string(&kind_path, obj.take("kind")?)?;
1225    let Ok(kind) = kind_text.parse::<TargetTag>() else {
1226        return fail(&kind_path, ErrorKind::InvalidValue);
1227    };
1228    let resolved_path = decode_repo_path(&obj.field("path"), obj.take("path")?)?;
1229    match kind {
1230        TargetTag::Tree => {
1231            obj.finish()?;
1232            Ok(Target::Tree {
1233                path: resolved_path,
1234            })
1235        }
1236        TargetTag::Blob => {
1237            let mode_path = obj.field("mode");
1238            let mode_text = de::string(&mode_path, obj.take("mode")?)?;
1239            let Ok(mode) = mode_text.parse::<BlobMode>() else {
1240                return fail(&mode_path, ErrorKind::InvalidValue);
1241            };
1242            let content = decode_resolution_content(&obj.field("content"), obj.take("content")?)?;
1243            obj.finish()?;
1244            Ok(Target::Blob(BlobTarget {
1245                path: resolved_path,
1246                mode,
1247                content,
1248            }))
1249        }
1250    }
1251}
1252
1253fn decode_resolution_content(path: &str, value: Value) -> Result<BlobContent, Error> {
1254    let mut obj = Obj::new(path, value)?;
1255    let kind_path = obj.field("kind");
1256    let kind_text = de::string(&kind_path, obj.take("kind")?)?;
1257    let Ok(kind) = kind_text.parse::<BlobContentTag>() else {
1258        return fail(&kind_path, ErrorKind::InvalidValue);
1259    };
1260    let raw_digest = decode_digest(&obj.field("raw_digest"), obj.take("raw_digest")?)?;
1261    match kind {
1262        BlobContentTag::Available => {
1263            let projection_digest = decode_digest(
1264                &obj.field("projection_digest"),
1265                obj.take("projection_digest")?,
1266            )?;
1267            obj.finish()?;
1268            Ok(BlobContent::Available {
1269                raw_digest,
1270                projection_digest,
1271            })
1272        }
1273        BlobContentTag::LfsPointer => {
1274            obj.finish()?;
1275            Ok(BlobContent::LfsPointer { raw_digest })
1276        }
1277    }
1278}
1279
1280struct DecodedFact {
1281    fact: Fact,
1282    fact_digest: Digest,
1283    finding_key: Digest,
1284}
1285
1286fn decode_fact(path: &str, value: Value) -> Result<DecodedFact, Error> {
1287    let fact_digest = hj(FACT_DOMAIN, &value);
1288    let mut obj = Obj::new(path, value)?;
1289    de::const_str(&obj.field("schema"), obj.take("schema")?, FACT_SCHEMA)?;
1290    let finding_kind =
1291        EligibleFindingKind::decode(&obj.field("finding_kind"), obj.take("finding_kind")?)?;
1292    let key_path = obj.field("key_input");
1293    let (key_input, finding_key) = decode_key_input(&key_path, obj.take("key_input")?)?;
1294    let evidence_path = obj.field("evidence");
1295    let mut evidence = Obj::new(&evidence_path, obj.take("evidence")?)?;
1296    de::const_str(&evidence.field("kind"), evidence.take("kind")?, "reference")?;
1297    let resolution =
1298        decode_resolution(&evidence.field("resolution"), evidence.take("resolution")?)?;
1299    let multiplicity_path = evidence.field("occurrence_multiplicity");
1300    if de::integer(
1301        &multiplicity_path,
1302        evidence.take("occurrence_multiplicity")?,
1303    )? != 1
1304    {
1305        return fail(&multiplicity_path, ErrorKind::InvalidValue);
1306    }
1307    evidence.finish()?;
1308    obj.finish()?;
1309
1310    let Some(fact) = Fact::new(key_input, resolution) else {
1311        return fail(path, ErrorKind::Inconsistent);
1312    };
1313    if fact.finding_kind() != finding_kind {
1314        return fail(path, ErrorKind::Inconsistent);
1315    }
1316    Ok(DecodedFact {
1317        fact,
1318        fact_digest,
1319        finding_key,
1320    })
1321}
1322
1323struct ItemCore {
1324    finding_key: Digest,
1325    fact: Fact,
1326    fact_digest: Digest,
1327    owner: OwnerId,
1328    reason: String,
1329    created_at: UtcInstant,
1330    expires_at: UtcInstant,
1331}
1332
1333fn decode_item_core(obj: &mut Obj, fact_field: &str) -> Result<ItemCore, Error> {
1334    let finding_key_path = obj.field("finding_key");
1335    let finding_key = decode_digest(&finding_key_path, obj.take("finding_key")?)?;
1336    let fact_path = obj.field(fact_field);
1337    let decoded_fact = decode_fact(&fact_path, obj.take(fact_field)?)?;
1338    if finding_key != decoded_fact.finding_key {
1339        return fail(&finding_key_path, ErrorKind::DigestMismatch);
1340    }
1341    let fact_digest_field = format!("{fact_field}_digest");
1342    let fact_digest_path = obj.field(&fact_digest_field);
1343    let fact_digest = decode_digest(&fact_digest_path, obj.take(&fact_digest_field)?)?;
1344    if fact_digest != decoded_fact.fact_digest {
1345        return fail(&fact_digest_path, ErrorKind::DigestMismatch);
1346    }
1347    let owner = decode_owner(&obj.field("owner"), obj.take("owner")?)?;
1348    let reason = decode_reason(&obj.field("reason"), obj.take("reason")?)?;
1349    let created_at = decode_instant(&obj.field("created_at"), obj.take("created_at")?)?;
1350    let expires_at = decode_instant(&obj.field("expires_at"), obj.take("expires_at")?)?;
1351    Ok(ItemCore {
1352        finding_key,
1353        fact: decoded_fact.fact,
1354        fact_digest,
1355        owner,
1356        reason,
1357        created_at,
1358        expires_at,
1359    })
1360}
1361
1362fn decode_debt_item(path: &str, value: Value) -> Result<DebtItem, Error> {
1363    let mut obj = Obj::new(path, value)?;
1364    let debt_id = decode_artifact_id(&obj.field("debt_id"), obj.take("debt_id")?)?;
1365    let core = decode_item_core(&mut obj, "accepted_fact")?;
1366    obj.finish()?;
1367    if core.created_at >= core.expires_at {
1368        return fail(path, ErrorKind::Inconsistent);
1369    }
1370    Ok(DebtItem {
1371        debt_id,
1372        finding_key: core.finding_key,
1373        accepted_fact: core.fact,
1374        accepted_fact_digest: core.fact_digest,
1375        owner: core.owner,
1376        reason: core.reason,
1377        created_at: core.created_at,
1378        expires_at: core.expires_at,
1379    })
1380}
1381
1382fn decode_waiver_item(path: &str, value: Value) -> Result<WaiverItem, Error> {
1383    let mut obj = Obj::new(path, value)?;
1384    let waiver_id = decode_artifact_id(&obj.field("waiver_id"), obj.take("waiver_id")?)?;
1385    let core = decode_item_core(&mut obj, "authorized_fact")?;
1386    let candidate_tree = decode_tree(&obj.field("candidate_tree"), obj.take("candidate_tree")?)?;
1387    let issuer = decode_owner(&obj.field("issuer"), obj.take("issuer")?)?;
1388    let not_before = decode_instant(&obj.field("not_before"), obj.take("not_before")?)?;
1389    de::const_str(
1390        &obj.field("residual_disposition"),
1391        obj.take("residual_disposition")?,
1392        "warn",
1393    )?;
1394    obj.finish()?;
1395    if core.created_at > not_before || not_before >= core.expires_at {
1396        return fail(path, ErrorKind::Inconsistent);
1397    }
1398    Ok(WaiverItem {
1399        waiver_id,
1400        finding_key: core.finding_key,
1401        authorized_fact: core.fact,
1402        authorized_fact_digest: core.fact_digest,
1403        candidate_tree,
1404        owner: core.owner,
1405        issuer,
1406        reason: core.reason,
1407        created_at: core.created_at,
1408        not_before,
1409        expires_at: core.expires_at,
1410    })
1411}