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::{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
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/v1";
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.yml` is not the restricted metadata shape.
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    pub metadata: ActionMetadata,
60}
61
62/// The trusted bootstrap's validation, in the contract's order: choose from
63/// the closed platform table, resolve the reported commit to its reported
64/// tree, resolve the metadata, manifest, artifact, every lockfile, and every
65/// runtime path as regular non-symlink blobs in that tree, require the parsed
66/// manifest to carry the constrained digest, require every recorded lockfile
67/// to recompute from the tree's bytes, verify every mode and checksum, verify the
68/// selected binary's plain SHA-256 and domain-separated engine digest, and
69/// require the executable's own header to name the same platform. Nothing is
70/// downloaded, installed, discovered, or resolved through `PATH`.
71///
72/// # Errors
73///
74/// The first refusal above. A refusal always precedes execution.
75pub 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    // The manifest's lock set is parse-bound to its own set digest, so what is
112    // left to prove is that the tree really carries those bytes: each recorded
113    // lockfile re-resolved as a regular blob and re-hashed under the same
114    // domain the release builder used. Without this, the one file that says
115    // which dependencies built the engine is the one file nothing checks.
116    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
172/// Resolves the reported action commit to its reported tree, requiring the
173/// commit to exist in the pinned object format and its tree OID to equal the
174/// reported one.
175fn 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
194/// Resolves one path in the pinned tree as a regular non-symlink blob,
195/// returning its bytes and its exact Git mode. Symlinks, gitlinks,
196/// directories, and absent entries all refuse.
197fn 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, &current, 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}