Skip to main content

amiss_bootstrap/
lib.rs

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
11/// The engine names itself with this domain over its own bytes, and the
12/// bootstrap recomputes the same value over the binary it validated. One
13/// definition, so the two can never drift apart and silently stop matching.
14pub 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/// The exact reason a validation refused, in the order the contract checks
20/// them. Every one fails before scanning; none of them is recoverable.
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub enum Refusal {
23    /// The runner's platform is outside the closed table.
24    UnsupportedPlatform,
25    /// The action repository, commit, or tree does not resolve as reported.
26    ActionTree,
27    /// The root action definition is not pinned by the runtime closure.
28    ActionMetadata,
29    /// A required path is absent, a symlink, a gitlink, or the wrong mode.
30    PathNotRegularBlob,
31    /// The manifest blob does not parse under the strict rules.
32    ManifestUnreadable,
33    /// The manifest's semantic digest differs from the constraint.
34    ManifestDigest,
35    /// A shipped lockfile's bytes do not recompute to the manifest's
36    /// recorded member digest.
37    DependencyLock,
38    /// No artifact matches the selected platform and name.
39    ArtifactSelection,
40    /// A runtime file's checksum or mode differs from its manifest row.
41    RuntimeClosure,
42    /// The selected binary's SHA-256 or engine digest differs.
43    EngineDigest,
44    /// The executable's own header names a different platform.
45    PlatformBinding,
46    /// The bootstrap's own bytes do not recompute to the pinned digest.
47    BootstrapDigest,
48}
49
50/// The validated release: everything the bootstrap proved before it is
51/// allowed to exec anything.
52#[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
61/// The trusted bootstrap's validation, in the contract's order: choose from
62/// the closed platform table, resolve the reported commit to its reported
63/// tree, resolve the metadata, manifest, artifact, every lockfile, and every
64/// runtime path as regular non-symlink blobs in that tree, require the parsed
65/// manifest to carry the constrained digest, require every recorded lockfile
66/// to recompute from the tree's bytes, verify every mode and checksum, verify the
67/// selected binary's plain SHA-256 and domain-separated engine digest, and
68/// require the executable's own header to name the same platform. Nothing is
69/// downloaded, installed, discovered, or resolved through `PATH`.
70///
71/// # Errors
72///
73/// The first refusal above. A refusal always precedes execution.
74pub 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    // The manifest's lock set is parse-bound to its own set digest, so what is
102    // left to prove is that the tree really carries those bytes: each recorded
103    // lockfile re-resolved as a regular blob and re-hashed under the same
104    // domain the release builder used. Without this, the one file that says
105    // which dependencies built the engine is the one file nothing checks.
106    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    // the one runnable file at the tree root must be a pinned closure row,
150    // or the action a workflow executes is the one file nothing checks
151    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
167/// Resolves the reported action commit to its reported tree, requiring the
168/// commit to exist in the pinned object format and its tree OID to equal the
169/// reported one.
170fn 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
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.
192fn 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| 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}