1pub mod build;
2pub mod supervise;
3
4use amiss_git::{GitResources, ObjectKind, Repository};
5use amiss_wire::action::{ActionMetadata, executable_platform, host_platform};
6use amiss_wire::controls::{ConstraintPlatform, ExecutionConstraintDescriptor, GitMode};
7use amiss_wire::digest::{Digest, RAW_EVIDENCE_DOMAIN, hb, sha256};
8use amiss_wire::manifest::{ReleaseArtifact, ReleaseManifest, RuntimeRole};
9use amiss_wire::model::{Oid, RepoPath};
10
11pub use amiss_wire::report::ENGINE_DOMAIN;
15
16pub const BOOTSTRAP_DOMAIN: &str = "amiss/scanner-action-bootstrap/v1";
17pub const ACTION_METADATA_PATH: &str = "action.yml";
18
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub enum Refusal {
23 UnsupportedPlatform,
25 ActionTree,
27 ActionMetadata,
29 PathNotRegularBlob,
31 ManifestUnreadable,
33 ManifestDigest,
35 DependencyLock,
38 ArtifactSelection,
40 RuntimeClosure,
42 EngineDigest,
44 PlatformBinding,
46 BootstrapDigest,
48}
49
50#[derive(Clone, Debug)]
53pub struct Validated {
54 pub manifest: ReleaseManifest,
55 pub artifact: ReleaseArtifact,
56 pub platform: ConstraintPlatform,
57 pub engine_digest: Digest,
58 pub binary: Vec<u8>,
59 pub metadata: ActionMetadata,
60}
61
62pub fn validate(
76 action: &Repository,
77 resources: &mut GitResources,
78 constraint: &ExecutionConstraintDescriptor,
79 bootstrap_bytes: &[u8],
80) -> Result<Validated, Refusal> {
81 if hb(BOOTSTRAP_DOMAIN, bootstrap_bytes) != constraint.bootstrap_digest {
82 return Err(Refusal::BootstrapDigest);
83 }
84 let platform = host_platform().ok_or(Refusal::UnsupportedPlatform)?;
85 if platform != constraint.selected_platform {
86 return Err(Refusal::PlatformBinding);
87 }
88
89 let tree = action_tree(action, resources, constraint)?;
90
91 let metadata_path =
92 RepoPath::new(ACTION_METADATA_PATH.to_owned()).ok_or(Refusal::ActionMetadata)?;
93 let (metadata_bytes, metadata_mode) = blob(action, resources, &tree, &metadata_path)?;
94 if metadata_mode != GitMode::RegularFile {
95 return Err(Refusal::PathNotRegularBlob);
96 }
97 let metadata =
98 ActionMetadata::parse(&metadata_bytes).map_err(|_defect| Refusal::ActionMetadata)?;
99
100 let (manifest_bytes, manifest_mode) =
101 blob(action, resources, &tree, &constraint.manifest_path)?;
102 if manifest_mode != GitMode::RegularFile {
103 return Err(Refusal::PathNotRegularBlob);
104 }
105 let manifest =
106 ReleaseManifest::parse(&manifest_bytes).map_err(|_defect| Refusal::ManifestUnreadable)?;
107 if manifest.digest != constraint.release_manifest_digest {
108 return Err(Refusal::ManifestDigest);
109 }
110
111 for (lock_path, lock_digest) in &manifest.dependency_lock.files {
117 let (lock_bytes, lock_mode) = blob(action, resources, &tree, lock_path)?;
118 if lock_mode != GitMode::RegularFile {
119 return Err(Refusal::PathNotRegularBlob);
120 }
121 if hb(RAW_EVIDENCE_DOMAIN, &lock_bytes) != *lock_digest {
122 return Err(Refusal::DependencyLock);
123 }
124 }
125
126 let artifact = manifest
127 .artifacts
128 .iter()
129 .find(|candidate| candidate.platform == platform)
130 .cloned()
131 .ok_or(Refusal::ArtifactSelection)?;
132
133 let mut binary: Option<Vec<u8>> = None;
134 for file in &artifact.runtime_files {
135 let (bytes, mode) = blob(action, resources, &tree, &file.path)?;
136 if mode != file.git_mode {
137 return Err(Refusal::RuntimeClosure);
138 }
139 if sha256(&bytes) != file.file_sha256 {
140 return Err(Refusal::RuntimeClosure);
141 }
142 if file.role == RuntimeRole::Executable {
143 binary = Some(bytes);
144 }
145 }
146 let binary = binary.ok_or(Refusal::RuntimeClosure)?;
147
148 if sha256(&binary) != artifact.binary_sha256 {
149 return Err(Refusal::EngineDigest);
150 }
151 let engine_digest = hb(ENGINE_DOMAIN, &binary);
152 if engine_digest != artifact.engine_digest {
153 return Err(Refusal::EngineDigest);
154 }
155 if executable_platform(&binary) != Some(platform) {
156 return Err(Refusal::PlatformBinding);
157 }
158 if artifact.launcher().ok_or(Refusal::RuntimeClosure)?.path != metadata.main {
159 return Err(Refusal::ActionMetadata);
160 }
161
162 Ok(Validated {
163 manifest,
164 artifact,
165 platform,
166 engine_digest,
167 binary,
168 metadata,
169 })
170}
171
172fn action_tree(
176 action: &Repository,
177 resources: &mut GitResources,
178 constraint: &ExecutionConstraintDescriptor,
179) -> Result<Oid, Refusal> {
180 if action.object_format() != constraint.action_object_format {
181 return Err(Refusal::ActionTree);
182 }
183 let object = action
184 .read_expected(resources, &constraint.action_commit_oid, ObjectKind::Commit)
185 .map_err(|_defect| Refusal::ActionTree)?;
186 let commit = amiss_git::parse_commit(action.object_format(), &object.body)
187 .map_err(|_defect| Refusal::ActionTree)?;
188 if commit.tree != constraint.action_tree_oid {
189 return Err(Refusal::ActionTree);
190 }
191 Ok(commit.tree)
192}
193
194fn blob(
198 action: &Repository,
199 resources: &mut GitResources,
200 tree: &Oid,
201 path: &RepoPath,
202) -> Result<(Vec<u8>, GitMode), Refusal> {
203 let mut current = tree.clone();
204 let mut segments = path.as_str().split('/').peekable();
205 while let Some(segment) = segments.next() {
206 let object = action
207 .read_expected(resources, ¤t, ObjectKind::Tree)
208 .map_err(|_defect| Refusal::PathNotRegularBlob)?;
209 let entries = amiss_git::parse_tree(action.object_format(), &object.body)
210 .map_err(|_defect| Refusal::PathNotRegularBlob)?;
211 let entry = entries
212 .iter()
213 .find(|entry| entry.name == segment.as_bytes())
214 .ok_or(Refusal::PathNotRegularBlob)?;
215 if segments.peek().is_some() {
216 if entry.mode != GitMode::Tree {
217 return Err(Refusal::PathNotRegularBlob);
218 }
219 current = entry.oid.clone();
220 continue;
221 }
222 let mode = entry.mode;
223 if mode != GitMode::RegularFile && mode != GitMode::ExecutableFile {
224 return Err(Refusal::PathNotRegularBlob);
225 }
226 let object = action
227 .read_expected(resources, &entry.oid, ObjectKind::Blob)
228 .map_err(|_defect| Refusal::PathNotRegularBlob)?;
229 return Ok((object.body, mode));
230 }
231 Err(Refusal::PathNotRegularBlob)
232}