Skip to main content

amiss_bootstrap/
lib.rs

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