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/v2";
135pub const PAYLOAD_SCHEMA: &str = "amiss/scanner-report-payload/v2";
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                    path_bytes: None,
393                    resource: None,
394                },
395                phase,
396            )
397        })
398        .collect();
399    let error_count = i64::try_from(error_rows.len()).ok()?;
400
401    let payload = object(vec![
402        ("schema", string(PAYLOAD_SCHEMA)),
403        ("compatibility", string("experimental")),
404        ("engine", engine_block(engine)),
405        (
406            "evaluation",
407            object(vec![
408                ("status", string("unavailable")),
409                (
410                    "request_digest",
411                    evaluation_request_digest
412                        .map_or(Value::Null, |digest| string(&digest.to_string())),
413                ),
414                ("reasons", Value::Array(reasons)),
415            ]),
416        ),
417        (
418            "controls",
419            object(vec![
420                ("status", string("unavailable")),
421                (
422                    "request_digest",
423                    controls_request_digest
424                        .map_or(Value::Null, |digest| string(&digest.to_string())),
425                ),
426                ("reasons", Value::Array(vec![string("not-parsed")])),
427            ]),
428        ),
429        (
430            "result",
431            object(vec![
432                ("complete", Value::Bool(false)),
433                ("status", string("incomplete")),
434                ("exit_code", Value::Integer(2)),
435                ("finding_count", Value::Integer(0)),
436                ("error_count", Value::Integer(error_count)),
437            ]),
438        ),
439        ("summary", zero_summary()),
440        ("documents", Value::Array(Vec::new())),
441        ("observations", Value::Array(Vec::new())),
442        ("findings", Value::Array(Vec::new())),
443        ("errors", Value::Array(error_rows)),
444    ]);
445
446    let payload_digest = hj(PAYLOAD_SCHEMA, &payload);
447    Some(object(vec![
448        ("schema", string(ENVELOPE_SCHEMA)),
449        ("payload", payload),
450        ("payload_digest", string(&payload_digest.to_string())),
451    ]))
452}
453
454/// One adapter's complete contract descriptor and its digest, which every
455/// occurrence embeds through its observation-identity input.
456#[must_use]
457pub fn adapter_contract(engine: &EngineProvenance, adapter: Adapter) -> (Value, Digest) {
458    let descriptor = object(vec![
459        ("schema", string(ADAPTER_CONTRACT_SCHEMA)),
460        ("adapter_id", string(adapter.adapter_id())),
461        ("parser_name", string(adapter.parser_name())),
462        ("parser_version", string(&engine.version)),
463        ("grammar_profile", string(adapter.grammar_profile())),
464        (
465            "frontmatter_contract",
466            string(adapter.frontmatter_contract()),
467        ),
468        ("source_projection", string(adapter.source_projection())),
469        ("structural_address", string(adapter.structural_address())),
470    ]);
471    let digest = hj(ADAPTER_CONTRACT_SCHEMA, &descriptor);
472    (descriptor, digest)
473}
474
475/// The complete engine block: contract, version, digest, provenance, policy
476/// version, and the three adapter descriptors with their digests.
477#[must_use]
478pub fn engine_block(engine: &EngineProvenance) -> Value {
479    let adapter_rows: Vec<Value> = Adapter::ALL
480        .iter()
481        .map(|adapter| {
482            let (descriptor, digest) = adapter_contract(engine, *adapter);
483            object(vec![
484                ("adapter_id", string(adapter.adapter_id())),
485                ("contract_descriptor", descriptor),
486                ("contract_digest", string(&digest.to_string())),
487            ])
488        })
489        .collect();
490    object(vec![
491        ("engine_contract", string(ENGINE_CONTRACT)),
492        ("engine_version", string(&engine.version)),
493        ("engine_digest", string(&engine.digest.to_string())),
494        ("action_provenance", object(vec![("kind", string("local"))])),
495        ("built_in_policy_version", string(BUILT_IN_POLICY_VERSION)),
496        ("adapters", Value::Array(adapter_rows)),
497    ])
498}
499
500fn zero_summary() -> Value {
501    let documents = [
502        "discovered",
503        "outside_document_set",
504        "scanned",
505        "unsupported",
506        "excluded_builtin",
507        "unlinked",
508        "frontmatter_documents",
509        "opaque_mdx_documents",
510        "opaque_html_documents",
511        "opaque_mdx_regions",
512        "opaque_mdx_bytes",
513        "opaque_html_regions",
514        "opaque_html_bytes",
515        "frontmatter_regions",
516        "frontmatter_bytes",
517    ];
518    let references = [
519        "extracted",
520        "explicit_local",
521        "same_repository_github",
522        "external_out_of_scope",
523        "unsupported",
524        "resolved",
525        "missing",
526    ];
527    let findings = [
528        "total",
529        "record",
530        "warn",
531        "fail",
532        "introduced",
533        "pre_existing",
534        "resolved",
535        "unknown",
536        "not_applicable",
537        "debt_tolerated",
538        "waived",
539        "analysis_errors",
540        "unsupported_capabilities",
541    ];
542    object(vec![
543        ("counts_complete", Value::Bool(false)),
544        ("documents", zero_counts(&documents)),
545        ("references", zero_counts(&references)),
546        ("findings", zero_counts(&findings)),
547        ("human_details_truncated", Value::Integer(0)),
548        ("governed_claims", Value::Integer(0)),
549        ("unattested_claims", Value::Integer(0)),
550    ])
551}
552
553fn zero_counts(fields: &[&str]) -> Value {
554    Value::Object(
555        fields
556            .iter()
557            .map(|field| ((*field).to_owned(), Value::Integer(0)))
558            .collect(),
559    )
560}
561
562fn object(members: Vec<(&str, Value)>) -> Value {
563    Value::Object(
564        members
565            .into_iter()
566            .map(|(key, value)| (key.to_owned(), value))
567            .collect(),
568    )
569}
570
571fn string(value: &str) -> Value {
572    Value::String(value.to_owned())
573}
574
575/// The six resolution statuses. The status is total in the code: no other
576/// pairing exists.
577#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
578pub enum ResolutionStatus {
579    Resolved,
580    Missing,
581    TypeMismatch,
582    Unsupported,
583    Invalid,
584    ExternalOutOfScope,
585}
586
587impl ResolutionStatus {
588    #[must_use]
589    pub const fn as_str(self) -> &'static str {
590        match self {
591            Self::Resolved => "resolved",
592            Self::Missing => "missing",
593            Self::TypeMismatch => "type-mismatch",
594            Self::Unsupported => "unsupported",
595            Self::Invalid => "invalid",
596            Self::ExternalOutOfScope => "external-out-of-scope",
597        }
598    }
599}
600
601/// The closed resolution codes in schema declaration order.
602#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
603pub enum ResolutionCode {
604    ExactPath,
605    PathNotFound,
606    TargetTypeMismatch,
607    SymlinkEntry,
608    GitlinkEntry,
609    UnsupportedQuerySemantics,
610    UnsupportedFragmentSemantics,
611    UnsupportedVersionScope,
612    SiteRouteUnsupported,
613    NetworkPathUnsupported,
614    CodeFragmentUnevaluated,
615    InvalidUri,
616    InvalidPercentEncoding,
617    DecodedPathControl,
618    PathTraversal,
619    BackslashSeparator,
620    EncodedSlash,
621    InvalidFragmentEncoding,
622    InvalidReference,
623    ExternalUrl,
624    ForeignRepository,
625}
626
627impl ResolutionCode {
628    #[must_use]
629    pub const fn as_str(self) -> &'static str {
630        match self {
631            Self::ExactPath => "exact-path",
632            Self::PathNotFound => "path-not-found",
633            Self::TargetTypeMismatch => "target-type-mismatch",
634            Self::SymlinkEntry => "symlink-entry",
635            Self::GitlinkEntry => "gitlink-entry",
636            Self::UnsupportedQuerySemantics => "unsupported-query-semantics",
637            Self::UnsupportedFragmentSemantics => "unsupported-fragment-semantics",
638            Self::UnsupportedVersionScope => "unsupported-version-scope",
639            Self::SiteRouteUnsupported => "site-route-unsupported",
640            Self::NetworkPathUnsupported => "network-path-unsupported",
641            Self::CodeFragmentUnevaluated => "code-fragment-unevaluated",
642            Self::InvalidUri => "invalid-uri",
643            Self::InvalidPercentEncoding => "invalid-percent-encoding",
644            Self::DecodedPathControl => "decoded-path-control",
645            Self::PathTraversal => "path-traversal",
646            Self::BackslashSeparator => "backslash-separator",
647            Self::EncodedSlash => "encoded-slash",
648            Self::InvalidFragmentEncoding => "invalid-fragment-encoding",
649            Self::InvalidReference => "invalid-reference",
650            Self::ExternalUrl => "external-url",
651            Self::ForeignRepository => "foreign-repository",
652        }
653    }
654
655    #[must_use]
656    pub const fn status(self) -> ResolutionStatus {
657        match self {
658            Self::ExactPath => ResolutionStatus::Resolved,
659            Self::PathNotFound => ResolutionStatus::Missing,
660            Self::TargetTypeMismatch => ResolutionStatus::TypeMismatch,
661            Self::SymlinkEntry
662            | Self::GitlinkEntry
663            | Self::UnsupportedQuerySemantics
664            | Self::UnsupportedFragmentSemantics
665            | Self::UnsupportedVersionScope
666            | Self::SiteRouteUnsupported
667            | Self::NetworkPathUnsupported
668            | Self::CodeFragmentUnevaluated => ResolutionStatus::Unsupported,
669            Self::InvalidUri
670            | Self::InvalidPercentEncoding
671            | Self::DecodedPathControl
672            | Self::PathTraversal
673            | Self::BackslashSeparator
674            | Self::EncodedSlash
675            | Self::InvalidFragmentEncoding
676            | Self::InvalidReference => ResolutionStatus::Invalid,
677            Self::ExternalUrl | Self::ForeignRepository => ResolutionStatus::ExternalOutOfScope,
678        }
679    }
680}
681
682/// The five target-intent variants an occurrence can carry.
683#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
684pub enum IntentKind {
685    RepositoryPath,
686    SameRepositoryGithub,
687    ExternalUrl,
688    SiteRoute,
689    Unsupported,
690}
691
692impl IntentKind {
693    #[must_use]
694    pub const fn as_str(self) -> &'static str {
695        match self {
696            Self::RepositoryPath => "repository-path",
697            Self::SameRepositoryGithub => "same-repository-github",
698            Self::ExternalUrl => "external-url",
699            Self::SiteRoute => "site-route",
700            Self::Unsupported => "unsupported",
701        }
702    }
703}
704
705/// The four finding scopes.
706#[derive(Clone, Copy, Debug, PartialEq, Eq)]
707pub enum FindingScope {
708    Reference,
709    Observation,
710    Document,
711    Control,
712}
713
714/// The closed disposition values a policy step can produce.
715#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
716pub enum Disposition {
717    Record,
718    Warn,
719    Fail,
720}
721
722impl Disposition {
723    #[must_use]
724    pub const fn as_str(self) -> &'static str {
725        match self {
726            Self::Record => "record",
727            Self::Warn => "warn",
728            Self::Fail => "fail",
729        }
730    }
731}
732
733/// The complete closed v0 finding taxonomy, in schema declaration order.
734#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
735pub enum FindingKind {
736    ExplicitTargetMissing,
737    ExplicitTargetTypeMismatch,
738    InvalidReference,
739    UnsupportedReferenceSemantics,
740    UnsupportedDocumentFormat,
741    UnsupportedTargetKind,
742    UnsupportedVersionScope,
743    UnsupportedCapability,
744    DependencyChangedSubjectUnchanged,
745    DependencyAndSubjectCochanged,
746    SubjectChanged,
747    ExplicitReferenceRemoved,
748    DocumentRemoved,
749    ExternalOutOfScope,
750    OpaqueMdxRegion,
751    OpaqueHtmlRegion,
752    ObservationCorrelationAmbiguous,
753    UnlinkedDocument,
754    PolicyWeakened,
755    CoverageReduced,
756    ControlPlaneChanged,
757    DebtWorsened,
758    DebtExpired,
759    WaiverInvalid,
760}
761
762impl FindingKind {
763    #[must_use]
764    pub const fn as_str(self) -> &'static str {
765        match self {
766            Self::ExplicitTargetMissing => "explicit-target-missing",
767            Self::ExplicitTargetTypeMismatch => "explicit-target-type-mismatch",
768            Self::InvalidReference => "invalid-reference",
769            Self::UnsupportedReferenceSemantics => "unsupported-reference-semantics",
770            Self::UnsupportedDocumentFormat => "unsupported-document-format",
771            Self::UnsupportedTargetKind => "unsupported-target-kind",
772            Self::UnsupportedVersionScope => "unsupported-version-scope",
773            Self::UnsupportedCapability => "unsupported-capability",
774            Self::DependencyChangedSubjectUnchanged => "dependency-changed-subject-unchanged",
775            Self::DependencyAndSubjectCochanged => "dependency-and-subject-cochanged",
776            Self::SubjectChanged => "subject-changed",
777            Self::ExplicitReferenceRemoved => "explicit-reference-removed",
778            Self::DocumentRemoved => "document-removed",
779            Self::ExternalOutOfScope => "external-out-of-scope",
780            Self::OpaqueMdxRegion => "opaque-mdx-region",
781            Self::OpaqueHtmlRegion => "opaque-html-region",
782            Self::ObservationCorrelationAmbiguous => "observation-correlation-ambiguous",
783            Self::UnlinkedDocument => "unlinked-document",
784            Self::PolicyWeakened => "policy-weakened",
785            Self::CoverageReduced => "coverage-reduced",
786            Self::ControlPlaneChanged => "control-plane-changed",
787            Self::DebtWorsened => "debt-worsened",
788            Self::DebtExpired => "debt-expired",
789            Self::WaiverInvalid => "waiver-invalid",
790        }
791    }
792
793    /// The closed key-scope assignment.
794    #[must_use]
795    pub const fn scope(self) -> FindingScope {
796        match self {
797            Self::ExplicitTargetMissing | Self::ExplicitTargetTypeMismatch => {
798                FindingScope::Reference
799            }
800            Self::InvalidReference
801            | Self::UnsupportedReferenceSemantics
802            | Self::UnsupportedTargetKind
803            | Self::UnsupportedVersionScope
804            | Self::DependencyChangedSubjectUnchanged
805            | Self::DependencyAndSubjectCochanged
806            | Self::SubjectChanged
807            | Self::ExplicitReferenceRemoved
808            | Self::ExternalOutOfScope
809            | Self::ObservationCorrelationAmbiguous => FindingScope::Observation,
810            Self::UnsupportedDocumentFormat
811            | Self::DocumentRemoved
812            | Self::OpaqueMdxRegion
813            | Self::OpaqueHtmlRegion
814            | Self::UnlinkedDocument => FindingScope::Document,
815            Self::UnsupportedCapability
816            | Self::PolicyWeakened
817            | Self::CoverageReduced
818            | Self::ControlPlaneChanged
819            | Self::DebtWorsened
820            | Self::DebtExpired
821            | Self::WaiverInvalid => FindingScope::Control,
822        }
823    }
824
825    /// The first policy-step result for a candidate fact under
826    /// `scanner-policy-defaults-v1`, per profile.
827    #[must_use]
828    pub const fn built_in_disposition(self, enforce: bool) -> Disposition {
829        match self {
830            Self::ExplicitTargetMissing
831            | Self::ExplicitTargetTypeMismatch
832            | Self::InvalidReference => {
833                if enforce {
834                    Disposition::Fail
835                } else {
836                    Disposition::Warn
837                }
838            }
839            Self::UnsupportedCapability
840            | Self::PolicyWeakened
841            | Self::CoverageReduced
842            | Self::ControlPlaneChanged
843            | Self::DebtWorsened
844            | Self::DebtExpired
845            | Self::WaiverInvalid => Disposition::Fail,
846            Self::DependencyChangedSubjectUnchanged | Self::ExplicitReferenceRemoved => {
847                Disposition::Warn
848            }
849            Self::UnsupportedReferenceSemantics
850            | Self::UnsupportedDocumentFormat
851            | Self::UnsupportedTargetKind
852            | Self::UnsupportedVersionScope
853            | Self::DependencyAndSubjectCochanged
854            | Self::SubjectChanged
855            | Self::DocumentRemoved
856            | Self::ExternalOutOfScope
857            | Self::OpaqueMdxRegion
858            | Self::OpaqueHtmlRegion
859            | Self::ObservationCorrelationAmbiguous
860            | Self::UnlinkedDocument => Disposition::Record,
861        }
862    }
863}
864
865pub const SANDBOX_SCHEMA: &str = "amiss/scanner-sandbox-profile/v1";
866
867/// The zero-capability sandbox descriptor the engine asserts for itself, and
868/// its digest. A future wrapper verifies rather than asserts it.
869#[must_use]
870pub fn sandbox_descriptor() -> (Value, Digest) {
871    let descriptor = object(vec![
872        ("schema", string(SANDBOX_SCHEMA)),
873        ("profile", string("scanner-v0-zero-capability-v1")),
874        ("isolation", string("process")),
875        ("network", string("denied")),
876        ("child_processes", string("denied")),
877        ("repository_processes", string("denied")),
878        ("credentials", string("absent")),
879        ("secrets", string("absent")),
880        ("shared_cache", string("denied")),
881        ("workspace", string("read-only")),
882        ("environment", string("scanner-process-env-v1")),
883        (
884            "physical_memory",
885            object(vec![("maximum_bytes", Value::Integer(1_073_741_824))]),
886        ),
887        (
888            "temporary_storage",
889            object(vec![
890                ("kind", string("private-bounded")),
891                ("maximum_bytes", Value::Integer(67_108_864)),
892            ]),
893        ),
894        (
895            "watchdog",
896            object(vec![("maximum_milliseconds", Value::Integer(120_000))]),
897        ),
898    ]);
899    let digest = hj(SANDBOX_SCHEMA, &descriptor);
900    (descriptor, digest)
901}
902
903impl FindingKind {
904    #[must_use]
905    pub const fn evidence_class(self) -> &'static str {
906        match self {
907            Self::ExplicitTargetMissing
908            | Self::ExplicitTargetTypeMismatch
909            | Self::InvalidReference => "deterministic-structural",
910            Self::UnsupportedCapability
911            | Self::UnsupportedReferenceSemantics
912            | Self::UnsupportedDocumentFormat
913            | Self::UnsupportedTargetKind
914            | Self::UnsupportedVersionScope => "unsupported",
915            Self::DependencyChangedSubjectUnchanged
916            | Self::DependencyAndSubjectCochanged
917            | Self::SubjectChanged => "impact-observation",
918            Self::ExplicitReferenceRemoved
919            | Self::DocumentRemoved
920            | Self::ExternalOutOfScope
921            | Self::OpaqueMdxRegion
922            | Self::OpaqueHtmlRegion
923            | Self::ObservationCorrelationAmbiguous
924            | Self::UnlinkedDocument => "coverage-boundary",
925            Self::PolicyWeakened
926            | Self::CoverageReduced
927            | Self::ControlPlaneChanged
928            | Self::DebtWorsened
929            | Self::DebtExpired
930            | Self::WaiverInvalid => "control-plane",
931        }
932    }
933
934    #[must_use]
935    pub const fn invariant_class(self) -> &'static str {
936        match self {
937            Self::ExplicitTargetMissing
938            | Self::ExplicitTargetTypeMismatch
939            | Self::InvalidReference => "ratcheted",
940            Self::UnsupportedCapability => "analysis-integrity",
941            Self::UnsupportedReferenceSemantics
942            | Self::UnsupportedDocumentFormat
943            | Self::UnsupportedTargetKind
944            | Self::UnsupportedVersionScope
945            | Self::DependencyChangedSubjectUnchanged
946            | Self::DependencyAndSubjectCochanged
947            | Self::SubjectChanged
948            | Self::ExplicitReferenceRemoved
949            | Self::DocumentRemoved
950            | Self::ExternalOutOfScope
951            | Self::OpaqueMdxRegion
952            | Self::OpaqueHtmlRegion
953            | Self::ObservationCorrelationAmbiguous
954            | Self::UnlinkedDocument => "advisory",
955            Self::PolicyWeakened
956            | Self::CoverageReduced
957            | Self::ControlPlaneChanged
958            | Self::DebtWorsened
959            | Self::DebtExpired
960            | Self::WaiverInvalid => "absolute",
961        }
962    }
963}
964
965/// One typed analysis error's reportable detail: the code, the exact path
966/// where the partition names one, the raw bytes of a name the report cannot
967/// hold as text, and the crossing triple for a resource error. Field order
968/// is the canonical error key, so the derived ordering is the wire's.
969#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
970pub struct ErrorDetail {
971    pub code: AnalysisErrorCode,
972    pub path: Option<crate::model::RepoPath>,
973    pub path_bytes: Option<Vec<u8>>,
974    pub resource: Option<(crate::controls::ResourceName, u64, u64)>,
975}
976
977impl ErrorDetail {
978    #[must_use]
979    pub fn phase(&self) -> &'static str {
980        self.resource.map_or_else(
981            || self.code.fixed_phase().unwrap_or("internal"),
982            |(name, _limit, _observed)| name.phase(),
983        )
984    }
985}
986
987/// One wire error row with its partition phase.
988#[must_use]
989pub fn error_row_value(detail: &ErrorDetail) -> Value {
990    error_row(detail, detail.phase())
991}
992
993fn error_row(detail: &ErrorDetail, phase: &str) -> Value {
994    let (resource, limit, observed) = detail.resource.map_or(
995        (Value::Null, Value::Null, Value::Null),
996        |(name, limit, observed)| {
997            (
998                string(name.as_str()),
999                Value::Integer(i64::try_from(limit).unwrap_or(i64::MAX)),
1000                Value::Integer(i64::try_from(observed).unwrap_or(i64::MAX)),
1001            )
1002        },
1003    );
1004    object(vec![
1005        ("phase", string(phase)),
1006        ("code", string(detail.code.as_str())),
1007        (
1008            "path",
1009            detail
1010                .path
1011                .as_ref()
1012                .map_or(Value::Null, crate::model::RepoPath::to_value),
1013        ),
1014        (
1015            "path_bytes_hex",
1016            detail.path_bytes.as_deref().map_or(Value::Null, |bytes| {
1017                Value::String(crate::model::hex_lower(bytes))
1018            }),
1019        ),
1020        ("resource", resource),
1021        ("configured_limit", limit),
1022        ("observed_lower_bound", observed),
1023    ])
1024}