Skip to main content

amiss_wire/
report.rs

1use std::collections::BTreeSet;
2
3use crate::digest::{Digest, hj};
4use crate::json::{Scratch, Sink, Value, canonical, canonical_length};
5use crate::model::Adapter;
6
7pub const ENGINE_CONTRACT: &str = "amiss/scanner-v0";
8
9/// The exact `machine-json-bytes` reservation: the report wire, canonical
10/// envelope plus the trailing newline, never exceeds this.
11pub const MACHINE_JSON_BYTES: u64 = 67_108_864;
12
13/// The fatal serializer's fixed scratch allowance: the staging buffer it
14/// reserves up front plus every transient allocation one streaming emission
15/// may make. The E0 maximal golden proves emission stays inside it.
16pub const FATAL_SCRATCH_BYTES: usize = 65_536;
17
18/// The streaming fatal-envelope serializer and its fixed scratch space. A
19/// binary reserves one before evaluator allocation accounting begins, so a
20/// fatal projection is always emittable: emission streams `JCS(envelope)`
21/// and the trailing newline through the reserved staging buffer without
22/// materializing the wire.
23pub struct FatalSerializer {
24    staging: Vec<u8>,
25    scratch: Scratch,
26}
27
28impl FatalSerializer {
29    /// Reserves the staging buffer and serializer scratch.
30    #[must_use]
31    pub fn new() -> Self {
32        Self {
33            staging: Vec::with_capacity(FATAL_SCRATCH_BYTES),
34            scratch: Scratch::new(),
35        }
36    }
37
38    /// Streams the envelope's wire (`JCS(envelope) || LF`) into the writer
39    /// through the reserved scratch and returns the byte count.
40    ///
41    /// # Errors
42    ///
43    /// The first writer error; the wire is incomplete in that case and the
44    /// caller treats the emission as failed.
45    pub fn emit(&mut self, envelope: &Value, out: &mut dyn std::io::Write) -> std::io::Result<u64> {
46        self.staging.clear();
47        let mut sink = StagedSink {
48            staging: &mut self.staging,
49            out,
50            written: 0,
51            error: None,
52        };
53        self.scratch.stream(envelope, &mut sink);
54        sink.write("\n");
55        let written = sink.flush();
56        self.staging.clear();
57        written
58    }
59
60    /// The materialized wire for a caller that must inspect the bytes (the
61    /// wrapper's acceptance): one counting pass sizes the allocation
62    /// exactly, then one streaming pass fills it.
63    #[must_use]
64    pub fn wire_bytes(&mut self, envelope: &Value) -> Vec<u8> {
65        let exact = canonical_length(envelope).saturating_add(1);
66        let mut wire = Vec::with_capacity(usize::try_from(exact).unwrap_or(0));
67        if self.emit(envelope, &mut wire).is_err() {
68            wire.clear();
69        }
70        wire
71    }
72}
73
74impl Default for FatalSerializer {
75    fn default() -> Self {
76        Self::new()
77    }
78}
79
80struct StagedSink<'a> {
81    staging: &'a mut Vec<u8>,
82    out: &'a mut dyn std::io::Write,
83    written: u64,
84    error: Option<std::io::Error>,
85}
86
87impl StagedSink<'_> {
88    fn drain(&mut self) {
89        if self.error.is_none() {
90            match self.out.write_all(self.staging) {
91                Ok(()) => {
92                    self.written = self
93                        .written
94                        .saturating_add(u64::try_from(self.staging.len()).unwrap_or(u64::MAX));
95                }
96                Err(defect) => self.error = Some(defect),
97            }
98        }
99        self.staging.clear();
100    }
101
102    fn flush(&mut self) -> std::io::Result<u64> {
103        self.drain();
104        match self.error.take() {
105            Some(defect) => Err(defect),
106            None => Ok(self.written),
107        }
108    }
109}
110
111impl Sink for StagedSink<'_> {
112    fn write(&mut self, piece: &str) {
113        if piece.len() >= FATAL_SCRATCH_BYTES {
114            self.drain();
115            if self.error.is_none() {
116                match self.out.write_all(piece.as_bytes()) {
117                    Ok(()) => {
118                        self.written = self
119                            .written
120                            .saturating_add(u64::try_from(piece.len()).unwrap_or(u64::MAX));
121                    }
122                    Err(defect) => self.error = Some(defect),
123                }
124            }
125            return;
126        }
127        if self.staging.len().saturating_add(piece.len()) > FATAL_SCRATCH_BYTES {
128            self.drain();
129        }
130        self.staging.extend_from_slice(piece.as_bytes());
131    }
132}
133pub const ENGINE_DOMAIN: &str = "amiss/scanner-engine/v1";
134pub const ENVELOPE_SCHEMA: &str = "amiss/scanner-report-envelope/v1";
135pub const PAYLOAD_SCHEMA: &str = "amiss/scanner-report-payload/v1";
136pub const ADAPTER_CONTRACT_SCHEMA: &str = "amiss/scanner-adapter-contract/v1";
137pub const BUILT_IN_POLICY_VERSION: &str = "scanner-policy-defaults-v1";
138
139/// The closed analysis-error codes in schema declaration order.
140#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
141pub enum AnalysisErrorCode {
142    InvalidInvocation,
143    UnsupportedProviderHost,
144    InvalidEvent,
145    InvalidProfile,
146    RequestUnreadable,
147    ConfigurationInvalid,
148    DuplicateJsonKey,
149    InvalidUtf8,
150    InvalidJson,
151    UnknownSchema,
152    UnknownField,
153    NoncanonicalArray,
154    DigestMismatch,
155    ControlBindingMismatch,
156    ExceptionOverlap,
157    UnsupportedCapability,
158    GitRepositoryUnavailable,
159    GitObjectMissing,
160    GitObjectWrongKind,
161    GitObjectUnreadable,
162    GitIndexInvalid,
163    GitIndexUnmerged,
164    GitIntentToAdd,
165    GitSnapshotChanged,
166    UnrepresentablePath,
167    DocumentInvalid,
168    ParserError,
169    ParserPanic,
170    InvalidSourceSpan,
171    ResolutionError,
172    ResourceLimitExceeded,
173    OutputLimitExceeded,
174    TooManyErrors,
175    ReportConstructionFailed,
176    SandboxViolation,
177    TrustedTimeInvalid,
178    InternalError,
179}
180
181impl AnalysisErrorCode {
182    #[must_use]
183    pub const fn as_str(self) -> &'static str {
184        match self {
185            Self::InvalidInvocation => "INVALID_INVOCATION",
186            Self::UnsupportedProviderHost => "UNSUPPORTED_PROVIDER_HOST",
187            Self::InvalidEvent => "INVALID_EVENT",
188            Self::InvalidProfile => "INVALID_PROFILE",
189            Self::RequestUnreadable => "REQUEST_UNREADABLE",
190            Self::ConfigurationInvalid => "CONFIGURATION_INVALID",
191            Self::DuplicateJsonKey => "DUPLICATE_JSON_KEY",
192            Self::InvalidUtf8 => "INVALID_UTF8",
193            Self::InvalidJson => "INVALID_JSON",
194            Self::UnknownSchema => "UNKNOWN_SCHEMA",
195            Self::UnknownField => "UNKNOWN_FIELD",
196            Self::NoncanonicalArray => "NONCANONICAL_ARRAY",
197            Self::DigestMismatch => "DIGEST_MISMATCH",
198            Self::ControlBindingMismatch => "CONTROL_BINDING_MISMATCH",
199            Self::ExceptionOverlap => "EXCEPTION_OVERLAP",
200            Self::UnsupportedCapability => "UNSUPPORTED_CAPABILITY",
201            Self::GitRepositoryUnavailable => "GIT_REPOSITORY_UNAVAILABLE",
202            Self::GitObjectMissing => "GIT_OBJECT_MISSING",
203            Self::GitObjectWrongKind => "GIT_OBJECT_WRONG_KIND",
204            Self::GitObjectUnreadable => "GIT_OBJECT_UNREADABLE",
205            Self::GitIndexInvalid => "GIT_INDEX_INVALID",
206            Self::GitIndexUnmerged => "GIT_INDEX_UNMERGED",
207            Self::GitIntentToAdd => "GIT_INTENT_TO_ADD",
208            Self::GitSnapshotChanged => "GIT_SNAPSHOT_CHANGED",
209            Self::UnrepresentablePath => "UNREPRESENTABLE_PATH",
210            Self::DocumentInvalid => "DOCUMENT_INVALID",
211            Self::ParserError => "PARSER_ERROR",
212            Self::ParserPanic => "PARSER_PANIC",
213            Self::InvalidSourceSpan => "INVALID_SOURCE_SPAN",
214            Self::ResolutionError => "RESOLUTION_ERROR",
215            Self::ResourceLimitExceeded => "RESOURCE_LIMIT_EXCEEDED",
216            Self::OutputLimitExceeded => "OUTPUT_LIMIT_EXCEEDED",
217            Self::TooManyErrors => "TOO_MANY_ERRORS",
218            Self::ReportConstructionFailed => "REPORT_CONSTRUCTION_FAILED",
219            Self::SandboxViolation => "SANDBOX_VIOLATION",
220            Self::TrustedTimeInvalid => "TRUSTED_TIME_INVALID",
221            Self::InternalError => "INTERNAL_ERROR",
222        }
223    }
224
225    /// Fixed phase for non-resource codes; `RESOURCE_LIMIT_EXCEEDED` takes its
226    /// phase from the resource partition and has none here.
227    #[must_use]
228    pub const fn fixed_phase(self) -> Option<&'static str> {
229        match self {
230            Self::InvalidInvocation
231            | Self::UnsupportedProviderHost
232            | Self::InvalidEvent
233            | Self::InvalidProfile
234            | Self::RequestUnreadable => Some("invocation"),
235            Self::ConfigurationInvalid
236            | Self::DuplicateJsonKey
237            | Self::InvalidUtf8
238            | Self::InvalidJson
239            | Self::UnknownSchema
240            | Self::UnknownField
241            | Self::NoncanonicalArray
242            | Self::DigestMismatch
243            | Self::ControlBindingMismatch
244            | Self::ExceptionOverlap
245            | Self::TrustedTimeInvalid => Some("configuration"),
246            Self::GitRepositoryUnavailable
247            | Self::GitObjectMissing
248            | Self::GitObjectWrongKind
249            | Self::GitObjectUnreadable
250            | Self::GitIndexInvalid
251            | Self::GitIndexUnmerged
252            | Self::GitIntentToAdd
253            | Self::GitSnapshotChanged
254            | Self::UnrepresentablePath => Some("git"),
255            Self::DocumentInvalid
256            | Self::ParserError
257            | Self::ParserPanic
258            | Self::InvalidSourceSpan => Some("parse"),
259            Self::ResolutionError => Some("resolution"),
260            Self::UnsupportedCapability => Some("policy"),
261            Self::OutputLimitExceeded | Self::ReportConstructionFailed => Some("output"),
262            Self::SandboxViolation | Self::TooManyErrors | Self::InternalError => Some("internal"),
263            Self::ResourceLimitExceeded => None,
264        }
265    }
266
267    const fn evaluation_reason(self) -> Option<&'static str> {
268        match self {
269            Self::InvalidInvocation => Some("invalid-invocation"),
270            Self::UnsupportedProviderHost => Some("unsupported-provider"),
271            Self::InvalidEvent => Some("invalid-event"),
272            Self::InvalidProfile => Some("invalid-profile"),
273            Self::RequestUnreadable => Some("request-unreadable"),
274            Self::ConfigurationInvalid
275            | Self::DuplicateJsonKey
276            | Self::InvalidUtf8
277            | Self::InvalidJson
278            | Self::UnknownSchema
279            | Self::UnknownField
280            | Self::NoncanonicalArray
281            | Self::DigestMismatch
282            | Self::ControlBindingMismatch
283            | Self::ExceptionOverlap
284            | Self::UnsupportedCapability
285            | Self::GitRepositoryUnavailable
286            | Self::GitObjectMissing
287            | Self::GitObjectWrongKind
288            | Self::GitObjectUnreadable
289            | Self::GitIndexInvalid
290            | Self::GitIndexUnmerged
291            | Self::GitIntentToAdd
292            | Self::GitSnapshotChanged
293            | Self::UnrepresentablePath
294            | Self::DocumentInvalid
295            | Self::ParserError
296            | Self::ParserPanic
297            | Self::InvalidSourceSpan
298            | Self::ResolutionError
299            | Self::ResourceLimitExceeded
300            | Self::OutputLimitExceeded
301            | Self::TooManyErrors
302            | Self::ReportConstructionFailed
303            | Self::SandboxViolation
304            | Self::TrustedTimeInvalid
305            | Self::InternalError => None,
306        }
307    }
308}
309
310#[derive(Clone, Debug, PartialEq, Eq)]
311pub struct EngineProvenance {
312    pub version: String,
313    pub digest: Digest,
314}
315
316/// Builds the canonical fatal-incomplete wire (`JCS(envelope) || LF`) for an
317/// invocation rejection: every detail array empty, every count zero, unavailable
318/// evaluation and controls with their reason sets, exit class 2.
319///
320/// Returns `None` when `codes` is empty or contains a non-invocation code.
321#[must_use]
322pub fn invocation_failure_wire(
323    engine: &EngineProvenance,
324    codes: &BTreeSet<AnalysisErrorCode>,
325) -> Option<Vec<u8>> {
326    unavailable_evaluation_wire(engine, codes, None, None)
327}
328
329/// The envelope value behind [`invocation_failure_wire`], for emission
330/// through the reserved fatal serializer.
331#[must_use]
332pub fn invocation_failure_envelope(
333    engine: &EngineProvenance,
334    codes: &BTreeSet<AnalysisErrorCode>,
335) -> Option<Value> {
336    unavailable_evaluation_envelope(engine, codes, None, None)
337}
338
339/// The fatal unavailable-evaluation envelope for the request-wire lane: the
340/// same closed projection, carrying each request's diagnostic digest where
341/// its byte stream was completely captured.
342///
343/// Returns `None` when no code is supplied or a code has no evaluation
344/// reason, exactly as the invocation form.
345#[must_use]
346pub fn unavailable_evaluation_wire(
347    engine: &EngineProvenance,
348    codes: &BTreeSet<AnalysisErrorCode>,
349    evaluation_request_digest: Option<Digest>,
350    controls_request_digest: Option<Digest>,
351) -> Option<Vec<u8>> {
352    let envelope = unavailable_evaluation_envelope(
353        engine,
354        codes,
355        evaluation_request_digest,
356        controls_request_digest,
357    )?;
358    let mut wire = canonical(&envelope);
359    wire.push(b'\n');
360    Some(wire)
361}
362
363/// The envelope value behind [`unavailable_evaluation_wire`], for emission
364/// through the reserved fatal serializer.
365#[must_use]
366pub fn unavailable_evaluation_envelope(
367    engine: &EngineProvenance,
368    codes: &BTreeSet<AnalysisErrorCode>,
369    evaluation_request_digest: Option<Digest>,
370    controls_request_digest: Option<Digest>,
371) -> Option<Value> {
372    if codes.is_empty() {
373        return None;
374    }
375    let mut reasons = Vec::new();
376    for code in codes {
377        reasons.push(Value::String(code.evaluation_reason()?.to_owned()));
378    }
379
380    let mut errors: Vec<(AnalysisErrorCode, &'static str)> = Vec::new();
381    for code in codes {
382        errors.push((*code, code.fixed_phase()?));
383    }
384    errors.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str()));
385    let error_rows: Vec<Value> = errors
386        .iter()
387        .map(|(code, phase)| {
388            error_row(
389                &ErrorDetail {
390                    code: *code,
391                    path: None,
392                    resource: None,
393                },
394                phase,
395            )
396        })
397        .collect();
398    let error_count = i64::try_from(error_rows.len()).ok()?;
399
400    let payload = object(vec![
401        ("schema", string(PAYLOAD_SCHEMA)),
402        ("compatibility", string("experimental")),
403        ("engine", engine_block(engine)),
404        (
405            "evaluation",
406            object(vec![
407                ("status", string("unavailable")),
408                (
409                    "request_digest",
410                    evaluation_request_digest
411                        .map_or(Value::Null, |digest| string(&digest.to_string())),
412                ),
413                ("reasons", Value::Array(reasons)),
414            ]),
415        ),
416        (
417            "controls",
418            object(vec![
419                ("status", string("unavailable")),
420                (
421                    "request_digest",
422                    controls_request_digest
423                        .map_or(Value::Null, |digest| string(&digest.to_string())),
424                ),
425                ("reasons", Value::Array(vec![string("not-parsed")])),
426            ]),
427        ),
428        (
429            "result",
430            object(vec![
431                ("complete", Value::Bool(false)),
432                ("status", string("incomplete")),
433                ("exit_code", Value::Integer(2)),
434                ("finding_count", Value::Integer(0)),
435                ("error_count", Value::Integer(error_count)),
436            ]),
437        ),
438        ("summary", zero_summary()),
439        ("documents", Value::Array(Vec::new())),
440        ("observations", Value::Array(Vec::new())),
441        ("findings", Value::Array(Vec::new())),
442        ("errors", Value::Array(error_rows)),
443    ]);
444
445    let payload_digest = hj(PAYLOAD_SCHEMA, &payload);
446    Some(object(vec![
447        ("schema", string(ENVELOPE_SCHEMA)),
448        ("payload", payload),
449        ("payload_digest", string(&payload_digest.to_string())),
450    ]))
451}
452
453/// One adapter's complete contract descriptor and its digest, which every
454/// occurrence embeds through its observation-identity input.
455#[must_use]
456pub fn adapter_contract(engine: &EngineProvenance, adapter: Adapter) -> (Value, Digest) {
457    let descriptor = object(vec![
458        ("schema", string(ADAPTER_CONTRACT_SCHEMA)),
459        ("adapter_id", string(adapter.adapter_id())),
460        ("parser_name", string(adapter.parser_name())),
461        ("parser_version", string(&engine.version)),
462        ("grammar_profile", string(adapter.grammar_profile())),
463        (
464            "frontmatter_contract",
465            string(adapter.frontmatter_contract()),
466        ),
467        ("source_projection", string(adapter.source_projection())),
468        ("structural_address", string(adapter.structural_address())),
469    ]);
470    let digest = hj(ADAPTER_CONTRACT_SCHEMA, &descriptor);
471    (descriptor, digest)
472}
473
474/// The complete engine block: contract, version, digest, provenance, policy
475/// version, and the three adapter descriptors with their digests.
476#[must_use]
477pub fn engine_block(engine: &EngineProvenance) -> Value {
478    let adapter_rows: Vec<Value> = Adapter::ALL
479        .iter()
480        .map(|adapter| {
481            let (descriptor, digest) = adapter_contract(engine, *adapter);
482            object(vec![
483                ("adapter_id", string(adapter.adapter_id())),
484                ("contract_descriptor", descriptor),
485                ("contract_digest", string(&digest.to_string())),
486            ])
487        })
488        .collect();
489    object(vec![
490        ("engine_contract", string(ENGINE_CONTRACT)),
491        ("engine_version", string(&engine.version)),
492        ("engine_digest", string(&engine.digest.to_string())),
493        ("action_provenance", object(vec![("kind", string("local"))])),
494        ("built_in_policy_version", string(BUILT_IN_POLICY_VERSION)),
495        ("adapters", Value::Array(adapter_rows)),
496    ])
497}
498
499fn zero_summary() -> Value {
500    let documents = [
501        "discovered",
502        "outside_document_set",
503        "scanned",
504        "unsupported",
505        "excluded_builtin",
506        "unlinked",
507        "frontmatter_documents",
508        "opaque_mdx_documents",
509        "opaque_html_documents",
510        "opaque_mdx_regions",
511        "opaque_mdx_bytes",
512        "opaque_html_regions",
513        "opaque_html_bytes",
514        "frontmatter_regions",
515        "frontmatter_bytes",
516    ];
517    let references = [
518        "extracted",
519        "explicit_local",
520        "same_repository_github",
521        "external_out_of_scope",
522        "unsupported",
523        "resolved",
524        "missing",
525    ];
526    let findings = [
527        "total",
528        "record",
529        "warn",
530        "fail",
531        "introduced",
532        "pre_existing",
533        "resolved",
534        "unknown",
535        "not_applicable",
536        "debt_tolerated",
537        "waived",
538        "analysis_errors",
539        "unsupported_capabilities",
540    ];
541    object(vec![
542        ("counts_complete", Value::Bool(false)),
543        ("documents", zero_counts(&documents)),
544        ("references", zero_counts(&references)),
545        ("findings", zero_counts(&findings)),
546        ("human_details_truncated", Value::Integer(0)),
547        ("governed_claims", Value::Integer(0)),
548        ("unattested_claims", Value::Integer(0)),
549    ])
550}
551
552fn zero_counts(fields: &[&str]) -> Value {
553    Value::Object(
554        fields
555            .iter()
556            .map(|field| ((*field).to_owned(), Value::Integer(0)))
557            .collect(),
558    )
559}
560
561fn object(members: Vec<(&str, Value)>) -> Value {
562    Value::Object(
563        members
564            .into_iter()
565            .map(|(key, value)| (key.to_owned(), value))
566            .collect(),
567    )
568}
569
570fn string(value: &str) -> Value {
571    Value::String(value.to_owned())
572}
573
574/// The six resolution statuses. The status is total in the code: no other
575/// pairing exists.
576#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
577pub enum ResolutionStatus {
578    Resolved,
579    Missing,
580    TypeMismatch,
581    Unsupported,
582    Invalid,
583    ExternalOutOfScope,
584}
585
586impl ResolutionStatus {
587    #[must_use]
588    pub const fn as_str(self) -> &'static str {
589        match self {
590            Self::Resolved => "resolved",
591            Self::Missing => "missing",
592            Self::TypeMismatch => "type-mismatch",
593            Self::Unsupported => "unsupported",
594            Self::Invalid => "invalid",
595            Self::ExternalOutOfScope => "external-out-of-scope",
596        }
597    }
598}
599
600/// The closed resolution codes in schema declaration order.
601#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
602pub enum ResolutionCode {
603    ExactPath,
604    PathNotFound,
605    TargetTypeMismatch,
606    SymlinkEntry,
607    GitlinkEntry,
608    UnsupportedQuerySemantics,
609    UnsupportedFragmentSemantics,
610    UnsupportedVersionScope,
611    SiteRouteUnsupported,
612    NetworkPathUnsupported,
613    CodeFragmentUnevaluated,
614    InvalidUri,
615    InvalidPercentEncoding,
616    DecodedPathControl,
617    PathTraversal,
618    BackslashSeparator,
619    EncodedSlash,
620    InvalidFragmentEncoding,
621    InvalidReference,
622    ExternalUrl,
623    ForeignRepository,
624}
625
626impl ResolutionCode {
627    #[must_use]
628    pub const fn as_str(self) -> &'static str {
629        match self {
630            Self::ExactPath => "exact-path",
631            Self::PathNotFound => "path-not-found",
632            Self::TargetTypeMismatch => "target-type-mismatch",
633            Self::SymlinkEntry => "symlink-entry",
634            Self::GitlinkEntry => "gitlink-entry",
635            Self::UnsupportedQuerySemantics => "unsupported-query-semantics",
636            Self::UnsupportedFragmentSemantics => "unsupported-fragment-semantics",
637            Self::UnsupportedVersionScope => "unsupported-version-scope",
638            Self::SiteRouteUnsupported => "site-route-unsupported",
639            Self::NetworkPathUnsupported => "network-path-unsupported",
640            Self::CodeFragmentUnevaluated => "code-fragment-unevaluated",
641            Self::InvalidUri => "invalid-uri",
642            Self::InvalidPercentEncoding => "invalid-percent-encoding",
643            Self::DecodedPathControl => "decoded-path-control",
644            Self::PathTraversal => "path-traversal",
645            Self::BackslashSeparator => "backslash-separator",
646            Self::EncodedSlash => "encoded-slash",
647            Self::InvalidFragmentEncoding => "invalid-fragment-encoding",
648            Self::InvalidReference => "invalid-reference",
649            Self::ExternalUrl => "external-url",
650            Self::ForeignRepository => "foreign-repository",
651        }
652    }
653
654    #[must_use]
655    pub const fn status(self) -> ResolutionStatus {
656        match self {
657            Self::ExactPath => ResolutionStatus::Resolved,
658            Self::PathNotFound => ResolutionStatus::Missing,
659            Self::TargetTypeMismatch => ResolutionStatus::TypeMismatch,
660            Self::SymlinkEntry
661            | Self::GitlinkEntry
662            | Self::UnsupportedQuerySemantics
663            | Self::UnsupportedFragmentSemantics
664            | Self::UnsupportedVersionScope
665            | Self::SiteRouteUnsupported
666            | Self::NetworkPathUnsupported
667            | Self::CodeFragmentUnevaluated => ResolutionStatus::Unsupported,
668            Self::InvalidUri
669            | Self::InvalidPercentEncoding
670            | Self::DecodedPathControl
671            | Self::PathTraversal
672            | Self::BackslashSeparator
673            | Self::EncodedSlash
674            | Self::InvalidFragmentEncoding
675            | Self::InvalidReference => ResolutionStatus::Invalid,
676            Self::ExternalUrl | Self::ForeignRepository => ResolutionStatus::ExternalOutOfScope,
677        }
678    }
679}
680
681/// The five target-intent variants an occurrence can carry.
682#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
683pub enum IntentKind {
684    RepositoryPath,
685    SameRepositoryGithub,
686    ExternalUrl,
687    SiteRoute,
688    Unsupported,
689}
690
691impl IntentKind {
692    #[must_use]
693    pub const fn as_str(self) -> &'static str {
694        match self {
695            Self::RepositoryPath => "repository-path",
696            Self::SameRepositoryGithub => "same-repository-github",
697            Self::ExternalUrl => "external-url",
698            Self::SiteRoute => "site-route",
699            Self::Unsupported => "unsupported",
700        }
701    }
702}
703
704/// The four finding scopes.
705#[derive(Clone, Copy, Debug, PartialEq, Eq)]
706pub enum FindingScope {
707    Reference,
708    Observation,
709    Document,
710    Control,
711}
712
713/// The closed disposition values a policy step can produce.
714#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
715pub enum Disposition {
716    Record,
717    Warn,
718    Fail,
719}
720
721impl Disposition {
722    #[must_use]
723    pub const fn as_str(self) -> &'static str {
724        match self {
725            Self::Record => "record",
726            Self::Warn => "warn",
727            Self::Fail => "fail",
728        }
729    }
730}
731
732/// The complete closed v0 finding taxonomy, in schema declaration order.
733#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
734pub enum FindingKind {
735    ExplicitTargetMissing,
736    ExplicitTargetTypeMismatch,
737    InvalidReference,
738    UnsupportedReferenceSemantics,
739    UnsupportedDocumentFormat,
740    UnsupportedTargetKind,
741    UnsupportedVersionScope,
742    UnsupportedCapability,
743    DependencyChangedSubjectUnchanged,
744    DependencyAndSubjectCochanged,
745    SubjectChanged,
746    ExplicitReferenceRemoved,
747    DocumentRemoved,
748    ExternalOutOfScope,
749    OpaqueMdxRegion,
750    OpaqueHtmlRegion,
751    ObservationCorrelationAmbiguous,
752    UnlinkedDocument,
753    PolicyWeakened,
754    CoverageReduced,
755    ControlPlaneChanged,
756    DebtWorsened,
757    DebtExpired,
758    WaiverInvalid,
759}
760
761impl FindingKind {
762    #[must_use]
763    pub const fn as_str(self) -> &'static str {
764        match self {
765            Self::ExplicitTargetMissing => "explicit-target-missing",
766            Self::ExplicitTargetTypeMismatch => "explicit-target-type-mismatch",
767            Self::InvalidReference => "invalid-reference",
768            Self::UnsupportedReferenceSemantics => "unsupported-reference-semantics",
769            Self::UnsupportedDocumentFormat => "unsupported-document-format",
770            Self::UnsupportedTargetKind => "unsupported-target-kind",
771            Self::UnsupportedVersionScope => "unsupported-version-scope",
772            Self::UnsupportedCapability => "unsupported-capability",
773            Self::DependencyChangedSubjectUnchanged => "dependency-changed-subject-unchanged",
774            Self::DependencyAndSubjectCochanged => "dependency-and-subject-cochanged",
775            Self::SubjectChanged => "subject-changed",
776            Self::ExplicitReferenceRemoved => "explicit-reference-removed",
777            Self::DocumentRemoved => "document-removed",
778            Self::ExternalOutOfScope => "external-out-of-scope",
779            Self::OpaqueMdxRegion => "opaque-mdx-region",
780            Self::OpaqueHtmlRegion => "opaque-html-region",
781            Self::ObservationCorrelationAmbiguous => "observation-correlation-ambiguous",
782            Self::UnlinkedDocument => "unlinked-document",
783            Self::PolicyWeakened => "policy-weakened",
784            Self::CoverageReduced => "coverage-reduced",
785            Self::ControlPlaneChanged => "control-plane-changed",
786            Self::DebtWorsened => "debt-worsened",
787            Self::DebtExpired => "debt-expired",
788            Self::WaiverInvalid => "waiver-invalid",
789        }
790    }
791
792    /// The closed key-scope assignment.
793    #[must_use]
794    pub const fn scope(self) -> FindingScope {
795        match self {
796            Self::ExplicitTargetMissing | Self::ExplicitTargetTypeMismatch => {
797                FindingScope::Reference
798            }
799            Self::InvalidReference
800            | Self::UnsupportedReferenceSemantics
801            | Self::UnsupportedTargetKind
802            | Self::UnsupportedVersionScope
803            | Self::DependencyChangedSubjectUnchanged
804            | Self::DependencyAndSubjectCochanged
805            | Self::SubjectChanged
806            | Self::ExplicitReferenceRemoved
807            | Self::ExternalOutOfScope
808            | Self::ObservationCorrelationAmbiguous => FindingScope::Observation,
809            Self::UnsupportedDocumentFormat
810            | Self::DocumentRemoved
811            | Self::OpaqueMdxRegion
812            | Self::OpaqueHtmlRegion
813            | Self::UnlinkedDocument => FindingScope::Document,
814            Self::UnsupportedCapability
815            | Self::PolicyWeakened
816            | Self::CoverageReduced
817            | Self::ControlPlaneChanged
818            | Self::DebtWorsened
819            | Self::DebtExpired
820            | Self::WaiverInvalid => FindingScope::Control,
821        }
822    }
823
824    /// The first policy-step result for a candidate fact under
825    /// `scanner-policy-defaults-v1`, per profile.
826    #[must_use]
827    pub const fn built_in_disposition(self, enforce: bool) -> Disposition {
828        match self {
829            Self::ExplicitTargetMissing
830            | Self::ExplicitTargetTypeMismatch
831            | Self::InvalidReference => {
832                if enforce {
833                    Disposition::Fail
834                } else {
835                    Disposition::Warn
836                }
837            }
838            Self::UnsupportedCapability
839            | Self::PolicyWeakened
840            | Self::CoverageReduced
841            | Self::ControlPlaneChanged
842            | Self::DebtWorsened
843            | Self::DebtExpired
844            | Self::WaiverInvalid => Disposition::Fail,
845            Self::DependencyChangedSubjectUnchanged | Self::ExplicitReferenceRemoved => {
846                Disposition::Warn
847            }
848            Self::UnsupportedReferenceSemantics
849            | Self::UnsupportedDocumentFormat
850            | Self::UnsupportedTargetKind
851            | Self::UnsupportedVersionScope
852            | Self::DependencyAndSubjectCochanged
853            | Self::SubjectChanged
854            | Self::DocumentRemoved
855            | Self::ExternalOutOfScope
856            | Self::OpaqueMdxRegion
857            | Self::OpaqueHtmlRegion
858            | Self::ObservationCorrelationAmbiguous
859            | Self::UnlinkedDocument => Disposition::Record,
860        }
861    }
862}
863
864pub const SANDBOX_SCHEMA: &str = "amiss/scanner-sandbox-profile/v1";
865
866/// The zero-capability sandbox descriptor the engine asserts for itself, and
867/// its digest. A future wrapper verifies rather than asserts it.
868#[must_use]
869pub fn sandbox_descriptor() -> (Value, Digest) {
870    let descriptor = object(vec![
871        ("schema", string(SANDBOX_SCHEMA)),
872        ("profile", string("scanner-v0-zero-capability-v1")),
873        ("isolation", string("process")),
874        ("network", string("denied")),
875        ("child_processes", string("denied")),
876        ("repository_processes", string("denied")),
877        ("credentials", string("absent")),
878        ("secrets", string("absent")),
879        ("shared_cache", string("denied")),
880        ("workspace", string("read-only")),
881        ("environment", string("scanner-process-env-v1")),
882        (
883            "physical_memory",
884            object(vec![("maximum_bytes", Value::Integer(1_073_741_824))]),
885        ),
886        (
887            "temporary_storage",
888            object(vec![
889                ("kind", string("private-bounded")),
890                ("maximum_bytes", Value::Integer(67_108_864)),
891            ]),
892        ),
893        (
894            "watchdog",
895            object(vec![("maximum_milliseconds", Value::Integer(120_000))]),
896        ),
897    ]);
898    let digest = hj(SANDBOX_SCHEMA, &descriptor);
899    (descriptor, digest)
900}
901
902impl FindingKind {
903    #[must_use]
904    pub const fn evidence_class(self) -> &'static str {
905        match self {
906            Self::ExplicitTargetMissing
907            | Self::ExplicitTargetTypeMismatch
908            | Self::InvalidReference => "deterministic-structural",
909            Self::UnsupportedCapability
910            | Self::UnsupportedReferenceSemantics
911            | Self::UnsupportedDocumentFormat
912            | Self::UnsupportedTargetKind
913            | Self::UnsupportedVersionScope => "unsupported",
914            Self::DependencyChangedSubjectUnchanged
915            | Self::DependencyAndSubjectCochanged
916            | Self::SubjectChanged => "impact-observation",
917            Self::ExplicitReferenceRemoved
918            | Self::DocumentRemoved
919            | Self::ExternalOutOfScope
920            | Self::OpaqueMdxRegion
921            | Self::OpaqueHtmlRegion
922            | Self::ObservationCorrelationAmbiguous
923            | Self::UnlinkedDocument => "coverage-boundary",
924            Self::PolicyWeakened
925            | Self::CoverageReduced
926            | Self::ControlPlaneChanged
927            | Self::DebtWorsened
928            | Self::DebtExpired
929            | Self::WaiverInvalid => "control-plane",
930        }
931    }
932
933    #[must_use]
934    pub const fn invariant_class(self) -> &'static str {
935        match self {
936            Self::ExplicitTargetMissing
937            | Self::ExplicitTargetTypeMismatch
938            | Self::InvalidReference => "ratcheted",
939            Self::UnsupportedCapability => "analysis-integrity",
940            Self::UnsupportedReferenceSemantics
941            | Self::UnsupportedDocumentFormat
942            | Self::UnsupportedTargetKind
943            | Self::UnsupportedVersionScope
944            | Self::DependencyChangedSubjectUnchanged
945            | Self::DependencyAndSubjectCochanged
946            | Self::SubjectChanged
947            | Self::ExplicitReferenceRemoved
948            | Self::DocumentRemoved
949            | Self::ExternalOutOfScope
950            | Self::OpaqueMdxRegion
951            | Self::OpaqueHtmlRegion
952            | Self::ObservationCorrelationAmbiguous
953            | Self::UnlinkedDocument => "advisory",
954            Self::PolicyWeakened
955            | Self::CoverageReduced
956            | Self::ControlPlaneChanged
957            | Self::DebtWorsened
958            | Self::DebtExpired
959            | Self::WaiverInvalid => "absolute",
960        }
961    }
962}
963
964/// One typed analysis error's reportable detail: the code, the exact path
965/// where the partition names one, and the crossing triple for a resource
966/// error.
967#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
968pub struct ErrorDetail {
969    pub code: AnalysisErrorCode,
970    pub path: Option<String>,
971    pub resource: Option<(crate::controls::ResourceName, u64, u64)>,
972}
973
974impl ErrorDetail {
975    #[must_use]
976    pub fn phase(&self) -> &'static str {
977        self.resource.map_or_else(
978            || self.code.fixed_phase().unwrap_or("internal"),
979            |(name, _limit, _observed)| name.phase(),
980        )
981    }
982}
983
984/// One wire error row with its partition phase.
985#[must_use]
986pub fn error_row_value(detail: &ErrorDetail) -> Value {
987    error_row(detail, detail.phase())
988}
989
990fn error_row(detail: &ErrorDetail, phase: &str) -> Value {
991    let (resource, limit, observed) = detail.resource.map_or(
992        (Value::Null, Value::Null, Value::Null),
993        |(name, limit, observed)| {
994            (
995                string(name.as_str()),
996                Value::Integer(i64::try_from(limit).unwrap_or(i64::MAX)),
997                Value::Integer(i64::try_from(observed).unwrap_or(i64::MAX)),
998            )
999        },
1000    );
1001    object(vec![
1002        ("phase", string(phase)),
1003        ("code", string(detail.code.as_str())),
1004        ("path", detail.path.as_deref().map_or(Value::Null, string)),
1005        ("path_bytes_hex", Value::Null),
1006        ("resource", resource),
1007        ("configured_limit", limit),
1008        ("observed_lower_bound", observed),
1009    ])
1010}