1pub mod build;
2pub mod supervise;
3
4use amiss_git::{GitResources, ObjectKind, Repository};
5use amiss_wire::action::{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, RepoPathText};
10
11pub use amiss_wire::report::ENGINE_DOMAIN;
15
16pub const BOOTSTRAP_DOMAIN: &str = "amiss/scanner-action-bootstrap";
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}
60
61pub fn validate(
75 action: &Repository,
76 resources: &mut GitResources,
77 constraint: &ExecutionConstraintDescriptor,
78 bootstrap_bytes: &[u8],
79) -> Result<Validated, Refusal> {
80 if hb(BOOTSTRAP_DOMAIN, bootstrap_bytes) != constraint.bootstrap_digest {
81 return Err(Refusal::BootstrapDigest);
82 }
83 let platform = host_platform().ok_or(Refusal::UnsupportedPlatform)?;
84 if platform != constraint.selected_platform {
85 return Err(Refusal::PlatformBinding);
86 }
87
88 let tree = action_tree(action, resources, constraint)?;
89
90 let (manifest_bytes, manifest_mode) =
91 blob(action, resources, &tree, &constraint.manifest_path)?;
92 if manifest_mode != GitMode::RegularFile {
93 return Err(Refusal::PathNotRegularBlob);
94 }
95 let manifest =
96 ReleaseManifest::parse(&manifest_bytes).map_err(|_defect| Refusal::ManifestUnreadable)?;
97 if manifest.digest != constraint.release_manifest_digest {
98 return Err(Refusal::ManifestDigest);
99 }
100
101 for (lock_path, lock_digest) in &manifest.dependency_lock.files {
107 let (lock_bytes, lock_mode) = blob(action, resources, &tree, lock_path)?;
108 if lock_mode != GitMode::RegularFile {
109 return Err(Refusal::PathNotRegularBlob);
110 }
111 if hb(RAW_EVIDENCE_DOMAIN, &lock_bytes) != *lock_digest {
112 return Err(Refusal::DependencyLock);
113 }
114 }
115
116 let artifact = manifest
117 .artifacts
118 .iter()
119 .find(|candidate| candidate.platform == platform)
120 .cloned()
121 .ok_or(Refusal::ArtifactSelection)?;
122
123 let mut binary: Option<Vec<u8>> = None;
124 for file in &artifact.runtime_files {
125 let (bytes, mode) = blob(action, resources, &tree, &file.path)?;
126 if mode != file.git_mode {
127 return Err(Refusal::RuntimeClosure);
128 }
129 if sha256(&bytes) != file.file_sha256 {
130 return Err(Refusal::RuntimeClosure);
131 }
132 if file.role == RuntimeRole::Executable {
133 binary = Some(bytes);
134 }
135 }
136 let binary = binary.ok_or(Refusal::RuntimeClosure)?;
137
138 if sha256(&binary) != artifact.binary_sha256 {
139 return Err(Refusal::EngineDigest);
140 }
141 let engine_digest = hb(ENGINE_DOMAIN, &binary);
142 if engine_digest != artifact.engine_digest {
143 return Err(Refusal::EngineDigest);
144 }
145 if executable_platform(&binary) != Some(platform) {
146 return Err(Refusal::PlatformBinding);
147 }
148 let _launcher = artifact.launcher().ok_or(Refusal::RuntimeClosure)?;
149 let action_pinned = artifact.runtime_files.iter().any(|file| {
152 file.role == RuntimeRole::RuntimeData && file.path.as_str() == ACTION_METADATA_PATH
153 });
154 if !action_pinned {
155 return Err(Refusal::ActionMetadata);
156 }
157
158 Ok(Validated {
159 manifest,
160 artifact,
161 platform,
162 engine_digest,
163 binary,
164 })
165}
166
167fn action_tree(
171 action: &Repository,
172 resources: &mut GitResources,
173 constraint: &ExecutionConstraintDescriptor,
174) -> Result<Oid, Refusal> {
175 if action.object_format() != constraint.action_object_format {
176 return Err(Refusal::ActionTree);
177 }
178 let object = action
179 .read_expected(resources, &constraint.action_commit_oid, ObjectKind::Commit)
180 .map_err(|_defect| Refusal::ActionTree)?;
181 let commit = amiss_git::parse_commit(action.object_format(), &object.body)
182 .map_err(|_defect| Refusal::ActionTree)?;
183 if commit.tree != constraint.action_tree_oid {
184 return Err(Refusal::ActionTree);
185 }
186 Ok(commit.tree)
187}
188
189fn 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| Refusal::PathNotRegularBlob)?;
204 let entries = amiss_git::parse_tree(action.object_format(), &object.body)
205 .map_err(|_defect| Refusal::PathNotRegularBlob)?;
206 let entry = entries
207 .iter()
208 .find(|entry| entry.name == segment.as_bytes())
209 .ok_or(Refusal::PathNotRegularBlob)?;
210 if segments.peek().is_some() {
211 if entry.mode != GitMode::Tree {
212 return Err(Refusal::PathNotRegularBlob);
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(Refusal::PathNotRegularBlob);
220 }
221 let object = action
222 .read_expected(resources, &entry.oid, ObjectKind::Blob)
223 .map_err(|_defect| Refusal::PathNotRegularBlob)?;
224 return Ok((object.body, mode));
225 }
226 Err(Refusal::PathNotRegularBlob)
227}