Skip to main content

amiss_bootstrap/
lib.rs

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
13/// The engine names itself with this domain over its own bytes, and the
14/// bootstrap recomputes the same value over the binary it validated. One
15/// definition, so the two can never drift apart and silently stop matching.
16pub 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/// A validation refusal and the trust decision it requires. The diagnostic is
23/// attached where the refusal originates, so adding a reason cannot leave a
24/// downstream classifier incomplete.
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26pub enum Refusal {
27    /// The trusted input was unavailable for this runner.
28    Unavailable(&'static str),
29    /// The trusted input contradicted its authenticated binding.
30    Tampered(&'static str),
31}
32
33const fn tampered(reason: &'static str) -> Refusal {
34    Refusal::Tampered(reason)
35}
36
37/// The validated release: everything the bootstrap proved before it is
38/// allowed to exec anything.
39#[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
48/// The trusted bootstrap's validation, in the contract's order: choose from
49/// the closed platform table, resolve the reported commit to its reported
50/// tree, resolve the metadata, manifest, artifact, every lockfile, and every
51/// runtime path as regular non-symlink blobs in that tree, require the parsed
52/// manifest to carry the constrained digest, require every recorded lockfile
53/// to recompute from the tree's bytes, verify every mode and checksum, verify the
54/// selected binary's plain SHA-256 and domain-separated engine digest, and
55/// require the executable's own header to name the same platform. Nothing is
56/// downloaded, installed, discovered, or resolved through `PATH`.
57///
58/// # Errors
59///
60/// The first refusal above. A refusal always precedes execution.
61pub 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    // The manifest's lock set is parse-bound to its own set digest, so what is
96    // left to prove is that the tree really carries those bytes: each recorded
97    // lockfile re-resolved as a regular blob and re-hashed under the same
98    // domain the release builder used. Without this, the one file that says
99    // which dependencies built the engine is the one file nothing checks.
100    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    // the one runnable file at the tree root must be a pinned closure row,
146    // or the action a workflow executes is the one file nothing checks
147    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
189/// Resolves one path in the pinned tree as a regular non-symlink blob,
190/// returning its bytes and its exact Git mode. Symlinks, gitlinks,
191/// directories, and absent entries all refuse.
192pub(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, &current, 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}