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