1use std::collections::BTreeSet;
2
3use strum::{EnumIter, IntoEnumIterator, IntoStaticStr};
4
5use crate::digest::{Digest, hj};
6use crate::json::{Scratch, Sink, Value, canonical, canonical_length};
7use crate::model::Adapter;
8
9pub const ENGINE_CONTRACT: &str = "amiss/scanner";
10
11pub const MACHINE_JSON_BYTES: u64 = 67_108_864;
14
15pub const EVALUATOR_MANAGED_MEMORY_BYTES: u64 = 1_073_741_824;
17
18pub const PRIVATE_TEMPORARY_STORAGE_BYTES: u64 = 67_108_864;
20
21pub const WATCHDOG_MILLISECONDS: u64 = 120_000;
23
24pub const FATAL_SCRATCH_BYTES: usize = 65_536;
28
29pub struct FatalSerializer {
35 staging: Vec<u8>,
36 scratch: Scratch,
37}
38
39impl FatalSerializer {
40 #[must_use]
42 pub fn new() -> Self {
43 Self {
44 staging: Vec::with_capacity(FATAL_SCRATCH_BYTES),
45 scratch: Scratch::new(),
46 }
47 }
48
49 pub fn emit(&mut self, envelope: &Value, out: &mut dyn std::io::Write) -> std::io::Result<u64> {
57 self.staging.clear();
58 let mut sink = StagedSink {
59 staging: &mut self.staging,
60 out,
61 written: 0,
62 error: None,
63 };
64 self.scratch.stream(envelope, &mut sink);
65 sink.write("\n");
66 let written = sink.flush();
67 self.staging.clear();
68 written
69 }
70
71 #[must_use]
75 pub fn wire_bytes(&mut self, envelope: &Value) -> Vec<u8> {
76 let exact = canonical_length(envelope).saturating_add(1);
77 let mut wire = Vec::with_capacity(usize::try_from(exact).unwrap_or(0));
78 if self.emit(envelope, &mut wire).is_err() {
79 wire.clear();
80 }
81 wire
82 }
83}
84
85impl Default for FatalSerializer {
86 fn default() -> Self {
87 Self::new()
88 }
89}
90
91struct StagedSink<'a> {
92 staging: &'a mut Vec<u8>,
93 out: &'a mut dyn std::io::Write,
94 written: u64,
95 error: Option<std::io::Error>,
96}
97
98impl StagedSink<'_> {
99 fn drain(&mut self) {
100 if self.error.is_none() {
101 match self.out.write_all(self.staging) {
102 Ok(()) => {
103 self.written = self
104 .written
105 .saturating_add(u64::try_from(self.staging.len()).unwrap_or(u64::MAX));
106 }
107 Err(defect) => self.error = Some(defect),
108 }
109 }
110 self.staging.clear();
111 }
112
113 fn flush(&mut self) -> std::io::Result<u64> {
114 self.drain();
115 match self.error.take() {
116 Some(defect) => Err(defect),
117 None => Ok(self.written),
118 }
119 }
120}
121
122impl Sink for StagedSink<'_> {
123 fn write(&mut self, piece: &str) {
124 if piece.len() >= FATAL_SCRATCH_BYTES {
125 self.drain();
126 if self.error.is_none() {
127 match self.out.write_all(piece.as_bytes()) {
128 Ok(()) => {
129 self.written = self
130 .written
131 .saturating_add(u64::try_from(piece.len()).unwrap_or(u64::MAX));
132 }
133 Err(defect) => self.error = Some(defect),
134 }
135 }
136 return;
137 }
138 if self.staging.len().saturating_add(piece.len()) > FATAL_SCRATCH_BYTES {
139 self.drain();
140 }
141 self.staging.extend_from_slice(piece.as_bytes());
142 }
143}
144pub const ENGINE_DOMAIN: &str = "amiss/scanner-engine";
145pub const ENVELOPE_SCHEMA: &str = "amiss/scanner-report-envelope";
146pub const PAYLOAD_SCHEMA: &str = "amiss/scanner-report-payload";
147pub const ADAPTER_CONTRACT_SCHEMA: &str = "amiss/scanner-adapter-contract";
148pub const BUILT_IN_POLICY: &str = "scanner-policy-defaults";
149
150#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, EnumIter)]
152pub enum AnalysisErrorCode {
153 InvalidInvocation,
154 InvalidEvent,
155 InvalidProfile,
156 RequestUnreadable,
157 ConfigurationInvalid,
158 DuplicateJsonKey,
159 InvalidUtf8,
160 InvalidJson,
161 UnknownSchema,
162 UnknownField,
163 NoncanonicalArray,
164 DigestMismatch,
165 ControlBindingMismatch,
166 ExceptionOverlap,
167 UnsupportedCapability,
168 GitRepositoryUnavailable,
169 GitObjectMissing,
170 GitObjectWrongKind,
171 GitObjectUnreadable,
172 GitIndexInvalid,
173 GitIndexUnmerged,
174 GitIntentToAdd,
175 GitSnapshotChanged,
176 UnrepresentablePath,
177 DocumentInvalid,
178 ParserError,
179 ParserPanic,
180 InvalidSourceSpan,
181 ResolutionError,
182 ResourceLimitExceeded,
183 OutputLimitExceeded,
184 TooManyErrors,
185 ReportConstructionFailed,
186 SandboxViolation,
187 TrustedTimeInvalid,
188 InternalError,
189}
190
191impl AnalysisErrorCode {
192 #[must_use]
194 pub fn all() -> impl ExactSizeIterator<Item = Self> {
195 Self::iter()
196 }
197
198 #[must_use]
202 pub const fn meaning(self) -> &'static str {
203 match self {
204 Self::InvalidInvocation => {
205 "the command line does not match the closed grammar; each documented option appears at most once and nothing else is accepted"
206 }
207 Self::InvalidEvent => {
208 "the declared repository, ref, or default-branch identity is not in canonical form; pass a lowercase owner and name and full refs/heads/ references"
209 }
210 Self::InvalidProfile => "the profile is not one of observe or enforce",
211 Self::RequestUnreadable => {
212 "the machine evaluation request bytes could not be read; nothing was evaluated"
213 }
214 Self::ConfigurationInvalid => {
215 "a policy or control input violates its schema; one unknown field or malformed value makes the whole file invalid rather than partly honored"
216 }
217 Self::DuplicateJsonKey => {
218 "a JSON input repeats an object key; strict parsing refuses the file instead of choosing one of the values"
219 }
220 Self::InvalidUtf8 => "a JSON input carries bytes that are not UTF-8",
221 Self::InvalidJson => "an input that must be JSON does not parse as strict JSON",
222 Self::UnknownSchema => {
223 "a JSON input declares a schema identifier this engine does not recognize"
224 }
225 Self::UnknownField => {
226 "a JSON input carries a field its closed schema does not define; unknown fields refuse rather than pass through unread"
227 }
228 Self::NoncanonicalArray => {
229 "a JSON input array violates its required canonical ordering or uniqueness"
230 }
231 Self::DigestMismatch => {
232 "a digest carried by an input does not match the bytes it names; the input is stale or altered"
233 }
234 Self::ControlBindingMismatch => {
235 "an external control is bound to a different repository, ref, or run identity than this evaluation; nothing is applied and the run ends incomplete"
236 }
237 Self::ExceptionOverlap => {
238 "accepted exception items select the same finding more than once; overlap ends evaluation incomplete instead of double-suppressing"
239 }
240 Self::UnsupportedCapability => {
241 "a candidate document declares a reserved amiss: capability this engine does not implement; the run ends incomplete rather than guessing at the claim"
242 }
243 Self::GitRepositoryUnavailable => {
244 "the --repo path does not open as a Git repository of the declared object format"
245 }
246 Self::GitObjectMissing => {
247 "a commit, tree, or blob the run needs is absent from the object store; fetch full history or name commits the store holds"
248 }
249 Self::GitObjectWrongKind => {
250 "a Git object is not the kind its use requires, as when a named commit resolves to another type"
251 }
252 Self::GitObjectUnreadable => "a Git object exists but its bytes cannot be decoded",
253 Self::GitIndexInvalid => "the staged index file does not parse under the index grammar",
254 Self::GitIndexUnmerged => {
255 "the index holds unmerged conflict entries, so no single staged state exists; finish or abort the merge before checking the index"
256 }
257 Self::GitIntentToAdd => {
258 "the index holds an intent-to-add entry whose content is not staged; stage the file or drop the intent entry before checking the index"
259 }
260 Self::GitSnapshotChanged => {
261 "the staged index changed while the run was reading it; rerun when the repository is quiet"
262 }
263 Self::UnrepresentablePath => {
264 "a tree or index name is outside the path grammar, a backslash, a NUL, or a dot segment; the exact bytes are disclosed as hex"
265 }
266 Self::DocumentInvalid => {
267 "a discovered document's bytes cannot be decoded as its format requires; the run refuses instead of skipping the file and passing"
268 }
269 Self::ParserError => {
270 "the pinned parser failed on a document; the document is named and the run is incomplete rather than the file silently dropped"
271 }
272 Self::ParserPanic => {
273 "the pinned parser panicked on a document; the panic is caught and reported, and the run is incomplete"
274 }
275 Self::InvalidSourceSpan => {
276 "the parser returned a node whose byte span does not address the document; the parse is not trusted"
277 }
278 Self::ResolutionError => {
279 "reference resolution failed internally; the run ends incomplete rather than reporting around the gap"
280 }
281 Self::ResourceLimitExceeded => {
282 "a named resource crossed its ceiling; the row carries the resource, the configured limit, and the observed lower bound"
283 }
284 Self::OutputLimitExceeded => {
285 "the serialized report would cross the machine-json-bytes ceiling; the run ends incomplete instead of shortening the findings"
286 }
287 Self::TooManyErrors => {
288 "more distinct analysis errors accumulated than the retention ceiling; the lowest-keyed rows are kept and this sentinel stands for the rest"
289 }
290 Self::ReportConstructionFailed => {
291 "the report could not be constructed or emitted; the run has no trustworthy output"
292 }
293 Self::SandboxViolation => {
294 "the run breached its sandbox descriptor; the result is not trustworthy"
295 }
296 Self::TrustedTimeInvalid => {
297 "a control that needs trusted time has no statement that verifies, absent or failing its binding; the run will not act on an unverified clock"
298 }
299 Self::InternalError => {
300 "an engine invariant failed; this is a defect in Amiss, not in the input, and the run has no trustworthy result"
301 }
302 }
303 }
304
305 #[must_use]
306 pub const fn as_str(self) -> &'static str {
307 match self {
308 Self::InvalidInvocation => "INVALID_INVOCATION",
309 Self::InvalidEvent => "INVALID_EVENT",
310 Self::InvalidProfile => "INVALID_PROFILE",
311 Self::RequestUnreadable => "REQUEST_UNREADABLE",
312 Self::ConfigurationInvalid => "CONFIGURATION_INVALID",
313 Self::DuplicateJsonKey => "DUPLICATE_JSON_KEY",
314 Self::InvalidUtf8 => "INVALID_UTF8",
315 Self::InvalidJson => "INVALID_JSON",
316 Self::UnknownSchema => "UNKNOWN_SCHEMA",
317 Self::UnknownField => "UNKNOWN_FIELD",
318 Self::NoncanonicalArray => "NONCANONICAL_ARRAY",
319 Self::DigestMismatch => "DIGEST_MISMATCH",
320 Self::ControlBindingMismatch => "CONTROL_BINDING_MISMATCH",
321 Self::ExceptionOverlap => "EXCEPTION_OVERLAP",
322 Self::UnsupportedCapability => "UNSUPPORTED_CAPABILITY",
323 Self::GitRepositoryUnavailable => "GIT_REPOSITORY_UNAVAILABLE",
324 Self::GitObjectMissing => "GIT_OBJECT_MISSING",
325 Self::GitObjectWrongKind => "GIT_OBJECT_WRONG_KIND",
326 Self::GitObjectUnreadable => "GIT_OBJECT_UNREADABLE",
327 Self::GitIndexInvalid => "GIT_INDEX_INVALID",
328 Self::GitIndexUnmerged => "GIT_INDEX_UNMERGED",
329 Self::GitIntentToAdd => "GIT_INTENT_TO_ADD",
330 Self::GitSnapshotChanged => "GIT_SNAPSHOT_CHANGED",
331 Self::UnrepresentablePath => "UNREPRESENTABLE_PATH",
332 Self::DocumentInvalid => "DOCUMENT_INVALID",
333 Self::ParserError => "PARSER_ERROR",
334 Self::ParserPanic => "PARSER_PANIC",
335 Self::InvalidSourceSpan => "INVALID_SOURCE_SPAN",
336 Self::ResolutionError => "RESOLUTION_ERROR",
337 Self::ResourceLimitExceeded => "RESOURCE_LIMIT_EXCEEDED",
338 Self::OutputLimitExceeded => "OUTPUT_LIMIT_EXCEEDED",
339 Self::TooManyErrors => "TOO_MANY_ERRORS",
340 Self::ReportConstructionFailed => "REPORT_CONSTRUCTION_FAILED",
341 Self::SandboxViolation => "SANDBOX_VIOLATION",
342 Self::TrustedTimeInvalid => "TRUSTED_TIME_INVALID",
343 Self::InternalError => "INTERNAL_ERROR",
344 }
345 }
346
347 #[must_use]
350 pub const fn fixed_phase(self) -> Option<&'static str> {
351 match self {
352 Self::InvalidInvocation
353 | Self::InvalidEvent
354 | Self::InvalidProfile
355 | Self::RequestUnreadable => Some("invocation"),
356 Self::ConfigurationInvalid
357 | Self::DuplicateJsonKey
358 | Self::InvalidUtf8
359 | Self::InvalidJson
360 | Self::UnknownSchema
361 | Self::UnknownField
362 | Self::NoncanonicalArray
363 | Self::DigestMismatch
364 | Self::ControlBindingMismatch
365 | Self::ExceptionOverlap
366 | Self::TrustedTimeInvalid => Some("configuration"),
367 Self::GitRepositoryUnavailable
368 | Self::GitObjectMissing
369 | Self::GitObjectWrongKind
370 | Self::GitObjectUnreadable
371 | Self::GitIndexInvalid
372 | Self::GitIndexUnmerged
373 | Self::GitIntentToAdd
374 | Self::GitSnapshotChanged
375 | Self::UnrepresentablePath => Some("git"),
376 Self::DocumentInvalid
377 | Self::ParserError
378 | Self::ParserPanic
379 | Self::InvalidSourceSpan => Some("parse"),
380 Self::ResolutionError => Some("resolution"),
381 Self::UnsupportedCapability => Some("policy"),
382 Self::OutputLimitExceeded | Self::ReportConstructionFailed => Some("output"),
383 Self::SandboxViolation | Self::TooManyErrors | Self::InternalError => Some("internal"),
384 Self::ResourceLimitExceeded => None,
385 }
386 }
387
388 const fn evaluation_reason(self) -> Option<&'static str> {
389 match self {
390 Self::InvalidInvocation => Some("invalid-invocation"),
391 Self::InvalidEvent => Some("invalid-event"),
392 Self::InvalidProfile => Some("invalid-profile"),
393 Self::RequestUnreadable => Some("request-unreadable"),
394 Self::ConfigurationInvalid
395 | Self::DuplicateJsonKey
396 | Self::InvalidUtf8
397 | Self::InvalidJson
398 | Self::UnknownSchema
399 | Self::UnknownField
400 | Self::NoncanonicalArray
401 | Self::DigestMismatch
402 | Self::ControlBindingMismatch
403 | Self::ExceptionOverlap
404 | Self::UnsupportedCapability
405 | Self::GitRepositoryUnavailable
406 | Self::GitObjectMissing
407 | Self::GitObjectWrongKind
408 | Self::GitObjectUnreadable
409 | Self::GitIndexInvalid
410 | Self::GitIndexUnmerged
411 | Self::GitIntentToAdd
412 | Self::GitSnapshotChanged
413 | Self::UnrepresentablePath
414 | Self::DocumentInvalid
415 | Self::ParserError
416 | Self::ParserPanic
417 | Self::InvalidSourceSpan
418 | Self::ResolutionError
419 | Self::ResourceLimitExceeded
420 | Self::OutputLimitExceeded
421 | Self::TooManyErrors
422 | Self::ReportConstructionFailed
423 | Self::SandboxViolation
424 | Self::TrustedTimeInvalid
425 | Self::InternalError => None,
426 }
427 }
428}
429
430#[derive(Clone, Debug, PartialEq, Eq)]
431pub struct EngineProvenance {
432 pub version: String,
433 pub digest: Digest,
434}
435
436#[must_use]
442pub fn invocation_failure_wire(
443 engine: &EngineProvenance,
444 codes: &BTreeSet<AnalysisErrorCode>,
445) -> Option<Vec<u8>> {
446 unavailable_evaluation_wire(engine, codes, None, None)
447}
448
449#[must_use]
452pub fn invocation_failure_envelope(
453 engine: &EngineProvenance,
454 codes: &BTreeSet<AnalysisErrorCode>,
455) -> Option<Value> {
456 unavailable_evaluation_envelope(engine, codes, None, None)
457}
458
459#[must_use]
466pub fn unavailable_evaluation_wire(
467 engine: &EngineProvenance,
468 codes: &BTreeSet<AnalysisErrorCode>,
469 evaluation_request_digest: Option<Digest>,
470 controls_request_digest: Option<Digest>,
471) -> Option<Vec<u8>> {
472 let envelope = unavailable_evaluation_envelope(
473 engine,
474 codes,
475 evaluation_request_digest,
476 controls_request_digest,
477 )?;
478 let mut wire = canonical(&envelope);
479 wire.push(b'\n');
480 Some(wire)
481}
482
483#[must_use]
486pub fn unavailable_evaluation_envelope(
487 engine: &EngineProvenance,
488 codes: &BTreeSet<AnalysisErrorCode>,
489 evaluation_request_digest: Option<Digest>,
490 controls_request_digest: Option<Digest>,
491) -> Option<Value> {
492 if codes.is_empty() {
493 return None;
494 }
495 let mut reasons = Vec::new();
496 for code in codes {
497 reasons.push(Value::String(code.evaluation_reason()?.to_owned()));
498 }
499
500 let mut errors: Vec<(AnalysisErrorCode, &'static str)> = Vec::new();
501 for code in codes {
502 errors.push((*code, code.fixed_phase()?));
503 }
504 errors.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str()));
505 let error_rows: Vec<Value> = errors
506 .iter()
507 .map(|(code, phase)| {
508 error_row(
509 &ErrorDetail {
510 code: *code,
511 path: None,
512 path_bytes: None,
513 resource: None,
514 },
515 phase,
516 )
517 })
518 .collect();
519 let error_count = i64::try_from(error_rows.len()).ok()?;
520
521 let payload = object(vec![
522 ("schema", string(PAYLOAD_SCHEMA)),
523 ("compatibility", string("experimental")),
524 ("engine", engine_block(engine)),
525 (
526 "evaluation",
527 object(vec![
528 ("status", string("unavailable")),
529 (
530 "request_digest",
531 evaluation_request_digest
532 .map_or(Value::Null, |digest| string(&digest.to_string())),
533 ),
534 ("reasons", Value::Array(reasons)),
535 ]),
536 ),
537 (
538 "controls",
539 object(vec![
540 ("status", string("unavailable")),
541 (
542 "request_digest",
543 controls_request_digest
544 .map_or(Value::Null, |digest| string(&digest.to_string())),
545 ),
546 ("reasons", Value::Array(vec![string("not-parsed")])),
547 ]),
548 ),
549 (
550 "result",
551 object(vec![
552 ("complete", Value::Bool(false)),
553 ("status", string("incomplete")),
554 ("exit_code", Value::Integer(2)),
555 ("finding_count", Value::Integer(0)),
556 ("error_count", Value::Integer(error_count)),
557 ]),
558 ),
559 ("summary", zero_summary()),
560 ("documents", Value::Array(Vec::new())),
561 ("observations", Value::Array(Vec::new())),
562 ("findings", Value::Array(Vec::new())),
563 ("errors", Value::Array(error_rows)),
564 ]);
565
566 let payload_digest = hj(PAYLOAD_SCHEMA, &payload);
567 Some(object(vec![
568 ("schema", string(ENVELOPE_SCHEMA)),
569 ("payload", payload),
570 ("payload_digest", string(&payload_digest.to_string())),
571 ]))
572}
573
574#[must_use]
577pub fn adapter_contract(engine: &EngineProvenance, adapter: Adapter) -> (Value, Digest) {
578 let descriptor = object(vec![
579 ("schema", string(ADAPTER_CONTRACT_SCHEMA)),
580 ("adapter_id", string(adapter.adapter_id())),
581 ("parser_name", string(adapter.parser_name())),
582 ("parser_version", string(&engine.version)),
583 ("grammar_profile", string(adapter.grammar_profile())),
584 (
585 "frontmatter_contract",
586 string(adapter.frontmatter_contract()),
587 ),
588 ("source_projection", string(adapter.source_projection())),
589 ("structural_address", string(adapter.structural_address())),
590 ]);
591 let digest = hj(ADAPTER_CONTRACT_SCHEMA, &descriptor);
592 (descriptor, digest)
593}
594
595#[must_use]
598pub fn engine_block(engine: &EngineProvenance) -> Value {
599 let adapter_rows: Vec<Value> = Adapter::all()
600 .map(|adapter| {
601 let (descriptor, digest) = adapter_contract(engine, adapter);
602 object(vec![
603 ("adapter_id", string(adapter.adapter_id())),
604 ("contract_descriptor", descriptor),
605 ("contract_digest", string(&digest.to_string())),
606 ])
607 })
608 .collect();
609 object(vec![
610 ("engine_contract", string(ENGINE_CONTRACT)),
611 ("engine_version", string(&engine.version)),
612 ("engine_digest", string(&engine.digest.to_string())),
613 ("action_provenance", object(vec![("kind", string("local"))])),
614 ("built_in_policy", string(BUILT_IN_POLICY)),
615 ("adapters", Value::Array(adapter_rows)),
616 ])
617}
618
619fn zero_summary() -> Value {
620 let documents = [
621 "discovered",
622 "outside_document_set",
623 "scanned",
624 "unsupported",
625 "excluded_builtin",
626 "unlinked",
627 "frontmatter_documents",
628 "opaque_mdx_documents",
629 "opaque_html_documents",
630 "opaque_mdx_regions",
631 "opaque_mdx_bytes",
632 "opaque_html_regions",
633 "opaque_html_bytes",
634 "frontmatter_regions",
635 "frontmatter_bytes",
636 ];
637 let references = [
638 "extracted",
639 "explicit_local",
640 "same_repository",
641 "external_out_of_scope",
642 "unsupported",
643 "resolved",
644 "missing",
645 ];
646 let findings = [
647 "total",
648 "record",
649 "warn",
650 "fail",
651 "introduced",
652 "pre_existing",
653 "resolved",
654 "unknown",
655 "not_applicable",
656 "debt_tolerated",
657 "waived",
658 "analysis_errors",
659 "unsupported_capabilities",
660 ];
661 object(vec![
662 ("counts_complete", Value::Bool(false)),
663 ("documents", zero_counts(&documents)),
664 ("references", zero_counts(&references)),
665 ("findings", zero_counts(&findings)),
666 ("human_details_truncated", Value::Integer(0)),
667 ("governed_claims", Value::Integer(0)),
668 ("unattested_claims", Value::Integer(0)),
669 ])
670}
671
672fn zero_counts(fields: &[&str]) -> Value {
673 Value::Object(
674 fields
675 .iter()
676 .map(|field| ((*field).to_owned(), Value::Integer(0)))
677 .collect(),
678 )
679}
680
681fn object(members: Vec<(&str, Value)>) -> Value {
682 Value::Object(
683 members
684 .into_iter()
685 .map(|(key, value)| (key.to_owned(), value))
686 .collect(),
687 )
688}
689
690fn string(value: &str) -> Value {
691 Value::String(value.to_owned())
692}
693
694#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
697pub enum IntentKind {
698 RepositoryPath,
699 SameRepositoryGithub,
700 SameRepositoryGitlab,
701 SameRepositoryGitea,
702 ExternalUrl,
703 SiteRoute,
704 Unsupported,
705}
706
707impl IntentKind {
708 #[must_use]
709 pub const fn as_str(self) -> &'static str {
710 match self {
711 Self::RepositoryPath => "repository-path",
712 Self::SameRepositoryGithub => "same-repository-github",
713 Self::SameRepositoryGitlab => "same-repository-gitlab",
714 Self::SameRepositoryGitea => "same-repository-gitea",
715 Self::ExternalUrl => "external-url",
716 Self::SiteRoute => "site-route",
717 Self::Unsupported => "unsupported",
718 }
719 }
720}
721
722#[derive(Clone, Copy, Debug, PartialEq, Eq)]
724pub enum FindingScope {
725 Reference,
726 Observation,
727 Document,
728 Control,
729}
730
731#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
733pub enum Disposition {
734 Record,
735 Warn,
736 Fail,
737}
738
739impl Disposition {
740 #[must_use]
741 pub const fn as_str(self) -> &'static str {
742 match self {
743 Self::Record => "record",
744 Self::Warn => "warn",
745 Self::Fail => "fail",
746 }
747 }
748}
749
750#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, EnumIter, IntoStaticStr)]
752#[strum(serialize_all = "kebab-case")]
753pub enum FindingKind {
754 ExplicitTargetMissing,
755 ExplicitTargetTypeMismatch,
756 InvalidReference,
757 UnsupportedReferenceSemantics,
758 UnsupportedDocumentFormat,
759 UnsupportedTargetKind,
760 UnsupportedVersionScope,
761 UnsupportedCapability,
762 DependencyChangedSubjectUnchanged,
763 DependencyAndSubjectCochanged,
764 SubjectChanged,
765 ExplicitReferenceRemoved,
766 DocumentRemoved,
767 ExternalOutOfScope,
768 OpaqueMdxRegion,
769 OpaqueHtmlRegion,
770 ObservationCorrelationAmbiguous,
771 UnlinkedDocument,
772 PolicyWeakened,
773 CoverageReduced,
774 ControlPlaneChanged,
775 DebtWorsened,
776 DebtExpired,
777 WaiverInvalid,
778}
779
780impl FindingKind {
781 #[must_use]
783 pub fn all() -> impl ExactSizeIterator<Item = Self> {
784 Self::iter()
785 }
786
787 #[must_use]
788 pub fn as_str(self) -> &'static str {
789 self.into()
790 }
791
792 #[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 #[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";
865
866#[must_use]
869pub fn sandbox_descriptor() -> (Value, Digest) {
870 let descriptor = object(vec![
871 ("schema", string(SANDBOX_SCHEMA)),
872 ("profile", string("scanner-zero-capability")),
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")),
882 (
883 "physical_memory",
884 object(vec![(
885 "maximum_bytes",
886 Value::Integer(i64::try_from(EVALUATOR_MANAGED_MEMORY_BYTES).unwrap_or(i64::MAX)),
887 )]),
888 ),
889 (
890 "temporary_storage",
891 object(vec![
892 ("kind", string("private-bounded")),
893 (
894 "maximum_bytes",
895 Value::Integer(
896 i64::try_from(PRIVATE_TEMPORARY_STORAGE_BYTES).unwrap_or(i64::MAX),
897 ),
898 ),
899 ]),
900 ),
901 (
902 "watchdog",
903 object(vec![(
904 "maximum_milliseconds",
905 Value::Integer(i64::try_from(WATCHDOG_MILLISECONDS).unwrap_or(i64::MAX)),
906 )]),
907 ),
908 ]);
909 let digest = hj(SANDBOX_SCHEMA, &descriptor);
910 (descriptor, digest)
911}
912
913impl FindingKind {
914 #[must_use]
915 pub const fn evidence_class(self) -> &'static str {
916 match self {
917 Self::ExplicitTargetMissing
918 | Self::ExplicitTargetTypeMismatch
919 | Self::InvalidReference => "deterministic-structural",
920 Self::UnsupportedCapability
921 | Self::UnsupportedReferenceSemantics
922 | Self::UnsupportedDocumentFormat
923 | Self::UnsupportedTargetKind
924 | Self::UnsupportedVersionScope => "unsupported",
925 Self::DependencyChangedSubjectUnchanged
926 | Self::DependencyAndSubjectCochanged
927 | Self::SubjectChanged => "impact-observation",
928 Self::ExplicitReferenceRemoved
929 | Self::DocumentRemoved
930 | Self::ExternalOutOfScope
931 | Self::OpaqueMdxRegion
932 | Self::OpaqueHtmlRegion
933 | Self::ObservationCorrelationAmbiguous
934 | Self::UnlinkedDocument => "coverage-boundary",
935 Self::PolicyWeakened
936 | Self::CoverageReduced
937 | Self::ControlPlaneChanged
938 | Self::DebtWorsened
939 | Self::DebtExpired
940 | Self::WaiverInvalid => "control-plane",
941 }
942 }
943
944 #[must_use]
949 pub const fn meaning(self) -> &'static str {
950 match self {
951 Self::ExplicitTargetMissing => {
952 "a reference names a repository path, or a line range inside one, that the named tree does not hold; restore the target or correct the link"
953 }
954 Self::ExplicitTargetTypeMismatch => {
955 "the referenced path exists as a different kind than the reference promises, as when a trailing slash names a regular file; make the spelling match the target"
956 }
957 Self::InvalidReference => {
958 "the destination cannot name a repository target: it escapes the repository or carries a backslash, an encoded separator, or control bytes; fix the destination"
959 }
960 Self::UnsupportedReferenceSemantics => {
961 "the reference uses semantics Amiss does not evaluate, a heading fragment or a leading-slash site route; the unchecked part is declared instead of guessed"
962 }
963 Self::UnsupportedDocumentFormat => {
964 "a policy-included document has no parser in this engine; it is discovered and counted, and its content is never scanned"
965 }
966 Self::UnsupportedTargetKind => {
967 "the reference resolves to a symlink or submodule, which Amiss does not follow; the boundary is declared instead of crossed"
968 }
969 Self::UnsupportedVersionScope => {
970 "a forge URL names this repository at another version, a different branch, tag, or commit; only the candidate version is read, so the link is recognized and left unresolved"
971 }
972 Self::UnsupportedCapability => {
973 "a candidate document declares a reserved amiss: capability this engine does not implement; the run ends incomplete rather than guessing at the claim"
974 }
975 Self::DependencyChangedSubjectUnchanged => {
976 "the referenced content changed and the block citing it did not; a reason for a person to reread the prose, never a machine verdict that it is wrong"
977 }
978 Self::DependencyAndSubjectCochanged => {
979 "the referenced content and the block citing it changed together, the shape of a maintained page; recorded with nothing to act on"
980 }
981 Self::SubjectChanged => {
982 "the block holding the reference changed while its target did not; recorded so prose moving over an unchanged dependency stays visible"
983 }
984 Self::ExplicitReferenceRemoved => {
985 "a reference that existed in the base is gone from the candidate; removal may be deliberate, so this warns for review instead of blocking"
986 }
987 Self::DocumentRemoved => {
988 "a scanned document left the tree; recorded so the disappearance is a stated fact rather than a silent one"
989 }
990 Self::ExternalOutOfScope => {
991 "the destination is an external URL Amiss never fetches; counted, reported, and left alone"
992 }
993 Self::OpaqueMdxRegion => {
994 "an MDX expression region the parser cannot see into; a reference inside it is a stated blind spot, reported with size and place"
995 }
996 Self::OpaqueHtmlRegion => {
997 "a raw HTML region the parser cannot see into; a reference inside it is a stated blind spot, reported with size and place"
998 }
999 Self::ObservationCorrelationAmbiguous => {
1000 "an occurrence has more than one plausible counterpart across the comparison; Amiss never chooses by input order, so the match is recorded as undecided"
1001 }
1002 Self::UnlinkedDocument => {
1003 "a scanned document from which zero references were extracted; despite the name, it claims nothing about inbound links from other pages"
1004 }
1005 Self::PolicyWeakened => {
1006 "the candidate loosens its own repository policy, dropping an include, a protected path, or a raised disposition; loosening the rules is reported under the rules being loosened"
1007 }
1008 Self::CoverageReduced => {
1009 "a protected path is gone or not a scannable document while its protection stands; restore it or amend the protection in a reviewed change"
1010 }
1011 Self::ControlPlaneChanged => {
1012 "a floor-protected control path is not the identical present blob on both sides, in mode and content; the floor exists so control edits are always visible"
1013 }
1014 Self::DebtWorsened => {
1015 "the finding an accepted debt item names no longer matches the recorded fact; debt tolerates exactly the recorded state, so any drift fails"
1016 }
1017 Self::DebtExpired => {
1018 "trusted time reached a debt item's expiry while its finding persists; fix the finding or renew the debt in a reviewed change"
1019 }
1020 Self::WaiverInvalid => {
1021 "a waiver item cannot apply, expired against trusted time or issued outside the floor's authority; an invalid waiver suppresses nothing"
1022 }
1023 }
1024 }
1025
1026 #[must_use]
1027 pub const fn invariant_class(self) -> &'static str {
1028 match self {
1029 Self::ExplicitTargetMissing
1030 | Self::ExplicitTargetTypeMismatch
1031 | Self::InvalidReference => "ratcheted",
1032 Self::UnsupportedCapability => "analysis-integrity",
1033 Self::UnsupportedReferenceSemantics
1034 | Self::UnsupportedDocumentFormat
1035 | Self::UnsupportedTargetKind
1036 | Self::UnsupportedVersionScope
1037 | Self::DependencyChangedSubjectUnchanged
1038 | Self::DependencyAndSubjectCochanged
1039 | Self::SubjectChanged
1040 | Self::ExplicitReferenceRemoved
1041 | Self::DocumentRemoved
1042 | Self::ExternalOutOfScope
1043 | Self::OpaqueMdxRegion
1044 | Self::OpaqueHtmlRegion
1045 | Self::ObservationCorrelationAmbiguous
1046 | Self::UnlinkedDocument => "advisory",
1047 Self::PolicyWeakened
1048 | Self::CoverageReduced
1049 | Self::ControlPlaneChanged
1050 | Self::DebtWorsened
1051 | Self::DebtExpired
1052 | Self::WaiverInvalid => "absolute",
1053 }
1054 }
1055}
1056
1057#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
1062pub struct ErrorDetail {
1063 pub code: AnalysisErrorCode,
1064 pub path: Option<crate::model::RepoPath>,
1065 pub path_bytes: Option<Vec<u8>>,
1066 pub resource: Option<(crate::controls::ResourceName, u64, u64)>,
1067}
1068
1069impl ErrorDetail {
1070 #[must_use]
1071 pub fn phase(&self) -> &'static str {
1072 self.resource.map_or_else(
1073 || self.code.fixed_phase().unwrap_or("internal"),
1074 |(name, _limit, _observed)| name.phase(),
1075 )
1076 }
1077}
1078
1079#[must_use]
1081pub fn error_row_value(detail: &ErrorDetail) -> Value {
1082 error_row(detail, detail.phase())
1083}
1084
1085fn error_row(detail: &ErrorDetail, phase: &str) -> Value {
1086 let (resource, limit, observed) = detail.resource.map_or(
1087 (Value::Null, Value::Null, Value::Null),
1088 |(name, limit, observed)| {
1089 (
1090 string(name.as_str()),
1091 Value::Integer(i64::try_from(limit).unwrap_or(i64::MAX)),
1092 Value::Integer(i64::try_from(observed).unwrap_or(i64::MAX)),
1093 )
1094 },
1095 );
1096 object(vec![
1097 ("phase", string(phase)),
1098 ("code", string(detail.code.as_str())),
1099 ("description", string(detail.code.meaning())),
1100 (
1101 "path",
1102 detail
1103 .path
1104 .as_ref()
1105 .map_or(Value::Null, crate::model::RepoPath::to_value),
1106 ),
1107 (
1108 "path_bytes_hex",
1109 detail.path_bytes.as_deref().map_or(Value::Null, |bytes| {
1110 Value::String(crate::model::hex_lower(bytes))
1111 }),
1112 ),
1113 ("resource", resource),
1114 ("configured_limit", limit),
1115 ("observed_lower_bound", observed),
1116 ])
1117}