1pub mod build;
2pub mod result;
3pub mod supervise;
4
5use amiss_git::{GitResources, ObjectKind, Repository};
6use amiss_wire::action::{executable_platform, host_platform};
7use amiss_wire::controls::{ConstraintPlatform, ExecutionConstraintDescriptor, GitMode};
8use amiss_wire::digest::{Digest, RAW_EVIDENCE_DOMAIN, hb, sha256};
9use amiss_wire::manifest::{ReleaseArtifact, ReleaseManifest, RuntimeRole};
10use amiss_wire::model::{Oid, RepoPathText};
11
12pub use amiss_wire::report::ENGINE_DOMAIN;
16
17pub const BOOTSTRAP_DOMAIN: &str = "amiss/scanner-action-bootstrap";
18pub const ACTION_METADATA_PATH: &str = "action.yml";
19
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub enum Refusal {
25 Unavailable(&'static str),
27 Tampered(&'static str),
29}
30
31const fn tampered(reason: &'static str) -> Refusal {
32 Refusal::Tampered(reason)
33}
34
35#[derive(Clone, Debug)]
38pub struct Validated {
39 pub manifest: ReleaseManifest,
40 pub artifact: ReleaseArtifact,
41 pub platform: ConstraintPlatform,
42 pub engine_digest: Digest,
43 pub binary: Vec<u8>,
44}
45
46pub fn validate(
60 action: &Repository,
61 resources: &mut GitResources,
62 constraint: &ExecutionConstraintDescriptor,
63 bootstrap_bytes: &[u8],
64) -> Result<Validated, Refusal> {
65 if hb(BOOTSTRAP_DOMAIN, bootstrap_bytes) != constraint.bootstrap_digest {
66 return Err(tampered("bootstrap-digest-mismatch"));
67 }
68 let platform = host_platform().ok_or(Refusal::Unavailable("unsupported-platform"))?;
69 if platform != constraint.selected_platform {
70 return Err(tampered("platform-binding-mismatch"));
71 }
72
73 let tree = action_tree(action, resources, constraint)?;
74
75 let (manifest_bytes, manifest_mode) =
76 blob(action, resources, &tree, &constraint.manifest_path)?;
77 if manifest_mode != GitMode::RegularFile {
78 return Err(tampered("path-not-regular-blob"));
79 }
80 let manifest = ReleaseManifest::parse(&manifest_bytes)
81 .map_err(|_defect| tampered("manifest-unreadable"))?;
82 if manifest.digest != constraint.release_manifest_digest {
83 return Err(tampered("manifest-digest-mismatch"));
84 }
85
86 for (lock_path, lock_digest) in &manifest.dependency_lock.files {
92 let (lock_bytes, lock_mode) = blob(action, resources, &tree, lock_path)?;
93 if lock_mode != GitMode::RegularFile {
94 return Err(tampered("path-not-regular-blob"));
95 }
96 if hb(RAW_EVIDENCE_DOMAIN, &lock_bytes) != *lock_digest {
97 return Err(tampered("dependency-lock-mismatch"));
98 }
99 }
100
101 let artifact = manifest
102 .artifacts
103 .iter()
104 .find(|candidate| candidate.platform == platform)
105 .cloned()
106 .ok_or(tampered("artifact-selection-failed"))?;
107
108 let mut binary: Option<Vec<u8>> = None;
109 for file in &artifact.runtime_files {
110 let (bytes, mode) = blob(action, resources, &tree, &file.path)?;
111 if mode != file.git_mode {
112 return Err(tampered("runtime-closure-mismatch"));
113 }
114 if sha256(&bytes) != file.file_sha256 {
115 return Err(tampered("runtime-closure-mismatch"));
116 }
117 if file.role == RuntimeRole::Executable {
118 binary = Some(bytes);
119 }
120 }
121 let binary = binary.ok_or(tampered("runtime-closure-mismatch"))?;
122
123 if sha256(&binary) != artifact.binary_sha256 {
124 return Err(tampered("engine-digest-mismatch"));
125 }
126 let engine_digest = hb(ENGINE_DOMAIN, &binary);
127 if engine_digest != artifact.engine_digest {
128 return Err(tampered("engine-digest-mismatch"));
129 }
130 if executable_platform(&binary) != Some(platform) {
131 return Err(tampered("platform-binding-mismatch"));
132 }
133 let _launcher = artifact
134 .launcher()
135 .ok_or(tampered("runtime-closure-mismatch"))?;
136 let action_pinned = artifact.runtime_files.iter().any(|file| {
139 file.role == RuntimeRole::RuntimeData && file.path.as_str() == ACTION_METADATA_PATH
140 });
141 if !action_pinned {
142 return Err(tampered("action-metadata-invalid"));
143 }
144
145 Ok(Validated {
146 manifest,
147 artifact,
148 platform,
149 engine_digest,
150 binary,
151 })
152}
153
154fn action_tree(
158 action: &Repository,
159 resources: &mut GitResources,
160 constraint: &ExecutionConstraintDescriptor,
161) -> Result<Oid, Refusal> {
162 if action.object_format() != constraint.action_object_format {
163 return Err(tampered("action-tree-mismatch"));
164 }
165 let object = action
166 .read_expected(resources, &constraint.action_commit_oid, ObjectKind::Commit)
167 .map_err(|_defect| tampered("action-tree-mismatch"))?;
168 let commit = amiss_git::parse_commit(action.object_format(), &object.body)
169 .map_err(|_defect| tampered("action-tree-mismatch"))?;
170 if commit.tree != constraint.action_tree_oid {
171 return Err(tampered("action-tree-mismatch"));
172 }
173 Ok(commit.tree)
174}
175
176fn blob(
180 action: &Repository,
181 resources: &mut GitResources,
182 tree: &Oid,
183 path: &RepoPathText,
184) -> Result<(Vec<u8>, GitMode), Refusal> {
185 let mut current = tree.clone();
186 let mut segments = path.as_str().split('/').peekable();
187 while let Some(segment) = segments.next() {
188 let object = action
189 .read_expected(resources, ¤t, ObjectKind::Tree)
190 .map_err(|_defect| tampered("path-not-regular-blob"))?;
191 let entries = amiss_git::parse_tree(action.object_format(), &object.body)
192 .map_err(|_defect| tampered("path-not-regular-blob"))?;
193 let entry = entries
194 .iter()
195 .find(|entry| entry.name == segment.as_bytes())
196 .ok_or(tampered("path-not-regular-blob"))?;
197 if segments.peek().is_some() {
198 if entry.mode != GitMode::Tree {
199 return Err(tampered("path-not-regular-blob"));
200 }
201 current = entry.oid.clone();
202 continue;
203 }
204 let mode = entry.mode;
205 if mode != GitMode::RegularFile && mode != GitMode::ExecutableFile {
206 return Err(tampered("path-not-regular-blob"));
207 }
208 let object = action
209 .read_expected(resources, &entry.oid, ObjectKind::Blob)
210 .map_err(|_defect| tampered("path-not-regular-blob"))?;
211 return Ok((object.body, mode));
212 }
213 Err(tampered("path-not-regular-blob"))
214}