Skip to main content

amiss_wire/
manifest.rs

1use std::cmp::Ordering;
2
3use crate::controls::{ConstraintPlatform, GitMode, root};
4use crate::de::{self, Error, ErrorKind, Obj, fail};
5use crate::digest::{Digest, hj};
6use crate::json::Value;
7use crate::model::{ArtifactId, ObjectFormat, Oid, RepoPath, RepositoryIdentity};
8
9pub const MANIFEST_SCHEMA: &str = "amiss/scanner-release-manifest/v1";
10pub const DEPENDENCY_LOCK_SCHEMA: &str = "amiss/scanner-dependency-lock-input/v1";
11pub const MANIFEST_DOMAIN: &str = "amiss/scanner-release-manifest/v1";
12pub const DEPENDENCY_LOCK_DOMAIN: &str = "amiss/scanner-dependency-lock/v1";
13pub const RUNTIME_CONTRACT: &str = "manifest-closed-v1";
14pub const ENVIRONMENT_CONTRACT: &str = "scanner-process-env-v1";
15
16/// One runtime file of the reviewed action closure: a regular blob in the
17/// pinned action tree with its exact mode and plain SHA-256.
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct RuntimeFile {
20    pub path: RepoPath,
21    pub role: RuntimeRole,
22    pub git_mode: GitMode,
23    pub file_sha256: Digest,
24}
25
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub enum RuntimeRole {
28    Executable,
29    Launcher,
30    DynamicLibrary,
31    RuntimeData,
32}
33
34impl RuntimeRole {
35    #[must_use]
36    pub const fn as_str(self) -> &'static str {
37        match self {
38            Self::Executable => "executable",
39            Self::Launcher => "launcher",
40            Self::DynamicLibrary => "dynamic-library",
41            Self::RuntimeData => "runtime-data",
42        }
43    }
44
45    fn decode(path: &str, value: Value) -> Result<Self, Error> {
46        match de::string(path, value)?.as_str() {
47            "executable" => Ok(Self::Executable),
48            "launcher" => Ok(Self::Launcher),
49            "dynamic-library" => Ok(Self::DynamicLibrary),
50            "runtime-data" => Ok(Self::RuntimeData),
51            _ => fail(path, ErrorKind::InvalidValue),
52        }
53    }
54}
55
56/// One published platform artifact and its complete runtime closure.
57#[derive(Clone, Debug, PartialEq, Eq)]
58pub struct ReleaseArtifact {
59    pub platform: ConstraintPlatform,
60    pub artifact_name: ArtifactId,
61    pub tree_path: RepoPath,
62    pub binary_sha256: Digest,
63    pub engine_digest: Digest,
64    pub runtime_files: Vec<RuntimeFile>,
65}
66
67/// The build namespace: the repository and exact commit the release was
68/// built from.
69#[derive(Clone, Debug, PartialEq, Eq)]
70pub struct BuildSource {
71    pub repository: RepositoryIdentity,
72    pub object_format: ObjectFormat,
73    pub commit_oid: Oid,
74}
75
76/// Every build lockfile by canonical path and raw-evidence digest.
77#[derive(Clone, Debug, PartialEq, Eq)]
78pub struct DependencyLockInput {
79    pub files: Vec<(RepoPath, Digest)>,
80}
81
82/// The strict release manifest: the reviewed release label, its build
83/// namespace, the complete dependency-lock set, and one to six artifacts
84/// sorted by platform.
85#[derive(Clone, Debug, PartialEq, Eq)]
86pub struct ReleaseManifest {
87    pub digest: Digest,
88    pub engine_version: String,
89    pub build_source: BuildSource,
90    pub dependency_lock: DependencyLockInput,
91    pub dependency_lock_digest: Digest,
92    pub artifacts: Vec<ReleaseArtifact>,
93}
94
95impl ReleaseManifest {
96    /// Parses the manifest blob under the strict JSON rules, verifying the
97    /// closed shape, the sorted-unique orders, and the lock-set digest.
98    ///
99    /// # Errors
100    ///
101    /// The first typed defect: shape, unknown field, invalid value, a
102    /// noncanonical array order, a limit crossing, or a digest mismatch.
103    pub fn parse(bytes: &[u8]) -> Result<Self, Error> {
104        Self::decode(root(bytes)?)
105    }
106
107    /// Decodes an already-parsed value, which is how the wrapper checks the
108    /// embedded manifest against the blob it parsed.
109    ///
110    /// # Errors
111    ///
112    /// As [`ReleaseManifest::parse`].
113    pub fn decode(value: Value) -> Result<Self, Error> {
114        let digest = hj(MANIFEST_DOMAIN, &value);
115        let mut obj = Obj::new("$", value)?;
116        de::const_str(&obj.field("schema"), obj.take("schema")?, MANIFEST_SCHEMA)?;
117        let engine_version =
118            decode_version(&obj.field("engine_version"), obj.take("engine_version")?)?;
119        let build_source =
120            decode_build_source(&obj.field("build_source"), obj.take("build_source")?)?;
121        let lock_path = obj.field("dependency_lock");
122        let lock_value = obj.take("dependency_lock")?;
123        let computed_lock = hj(DEPENDENCY_LOCK_DOMAIN, &lock_value);
124        let dependency_lock = decode_lock(&lock_path, lock_value)?;
125        let dependency_lock_digest = decode_digest(
126            &obj.field("dependency_lock_digest"),
127            obj.take("dependency_lock_digest")?,
128        )?;
129        if dependency_lock_digest != computed_lock {
130            return fail(
131                &obj.field("dependency_lock_digest"),
132                ErrorKind::InvalidValue,
133            );
134        }
135        let artifacts_path = obj.field("artifacts");
136        let artifacts = decode_artifacts(&artifacts_path, obj.take("artifacts")?)?;
137        obj.finish()?;
138        Ok(Self {
139            digest,
140            engine_version,
141            build_source,
142            dependency_lock,
143            dependency_lock_digest,
144            artifacts,
145        })
146    }
147
148    /// The one artifact matching both the selected platform and name, per
149    /// the manifest's no-repeated-platform law.
150    #[must_use]
151    pub fn select(
152        &self,
153        platform: ConstraintPlatform,
154        name: &ArtifactId,
155    ) -> Option<&ReleaseArtifact> {
156        self.artifacts
157            .iter()
158            .find(|artifact| artifact.platform == platform && &artifact.artifact_name == name)
159    }
160}
161
162impl ReleaseArtifact {
163    /// The single `executable` row, which the closure law requires to name
164    /// `tree_path` with mode `100755` and the artifact's own checksum.
165    #[must_use]
166    pub fn executable(&self) -> Option<&RuntimeFile> {
167        let mut rows = self
168            .runtime_files
169            .iter()
170            .filter(|file| file.role == RuntimeRole::Executable);
171        let row = rows.next()?;
172        if rows.next().is_some()
173            || row.path != self.tree_path
174            || row.git_mode != GitMode::ExecutableFile
175            || row.file_sha256 != self.binary_sha256
176        {
177            return None;
178        }
179        Some(row)
180    }
181
182    /// The single `launcher` row, which the closure law requires to be a
183    /// regular mode-`100644` blob, named by the metadata's `runs.main`. The
184    /// required path never executes it. The closure pins its bytes anyway,
185    /// because `runs.main` is exactly what a `uses:` consumer would run, and a
186    /// row the manifest never mentions is a file nobody reviewed.
187    #[must_use]
188    pub fn launcher(&self) -> Option<&RuntimeFile> {
189        let mut rows = self
190            .runtime_files
191            .iter()
192            .filter(|file| file.role == RuntimeRole::Launcher);
193        let row = rows.next()?;
194        if rows.next().is_some() || row.git_mode != GitMode::RegularFile {
195            return None;
196        }
197        Some(row)
198    }
199}
200
201fn decode_version(path: &str, value: Value) -> Result<String, Error> {
202    let raw = de::string(path, value)?;
203    let (core, pre) = raw
204        .split_once('-')
205        .map_or((raw.as_str(), None), |(core, pre)| (core, Some(pre)));
206    let numeric: Vec<&str> = core.split('.').collect();
207    let shaped = raw.len() <= 64
208        && numeric.len() == 3
209        && numeric
210            .iter()
211            .all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
212        && pre.is_none_or(|text| {
213            !text.is_empty()
214                && text.bytes().all(|byte| {
215                    byte.is_ascii_lowercase()
216                        || byte.is_ascii_digit()
217                        || byte == b'.'
218                        || byte == b'-'
219                })
220        });
221    if shaped {
222        Ok(raw)
223    } else {
224        fail(path, ErrorKind::InvalidValue)
225    }
226}
227
228fn decode_build_source(path: &str, value: Value) -> Result<BuildSource, Error> {
229    let mut obj = Obj::new(path, value)?;
230    let repository =
231        crate::controls::decode_repository(&obj.field("repository"), obj.take("repository")?)?;
232    let format_path = obj.field("object_format");
233    let object_format = decode_object_format(&format_path, obj.take("object_format")?)?;
234    let commit_path = obj.field("commit_oid");
235    let commit_oid = Oid::new(
236        object_format,
237        de::string(&commit_path, obj.take("commit_oid")?)?,
238    )
239    .ok_or_else(|| Error::new(&commit_path, ErrorKind::InvalidValue))?;
240    obj.finish()?;
241    Ok(BuildSource {
242        repository,
243        object_format,
244        commit_oid,
245    })
246}
247
248fn decode_object_format(path: &str, value: Value) -> Result<ObjectFormat, Error> {
249    match de::string(path, value)?.as_str() {
250        "sha1" => Ok(ObjectFormat::Sha1),
251        "sha256" => Ok(ObjectFormat::Sha256),
252        _ => fail(path, ErrorKind::InvalidValue),
253    }
254}
255
256fn decode_lock(path: &str, value: Value) -> Result<DependencyLockInput, Error> {
257    let mut obj = Obj::new(path, value)?;
258    de::const_str(
259        &obj.field("schema"),
260        obj.take("schema")?,
261        DEPENDENCY_LOCK_SCHEMA,
262    )?;
263    let files_path = obj.field("files");
264    let rows = de::array(&files_path, obj.take("files")?)?;
265    obj.finish()?;
266    if rows.is_empty() || rows.len() > 32 {
267        return fail(&files_path, ErrorKind::LimitExceeded);
268    }
269    let mut files: Vec<(RepoPath, Digest)> = Vec::with_capacity(rows.len());
270    for (index, row) in rows.into_iter().enumerate() {
271        let row_path = format!("{files_path}[{index}]");
272        let mut file = Obj::new(&row_path, row)?;
273        let member = decode_repo_path(&file.field("path"), file.take("path")?)?;
274        let raw_digest = decode_digest(&file.field("raw_digest"), file.take("raw_digest")?)?;
275        file.finish()?;
276        files.push((member, raw_digest));
277    }
278    sorted_unique(&files_path, &files, |a, b| a.0.as_str().cmp(b.0.as_str()))?;
279    Ok(DependencyLockInput { files })
280}
281
282fn decode_artifacts(path: &str, value: Value) -> Result<Vec<ReleaseArtifact>, Error> {
283    let rows = de::array(path, value)?;
284    if rows.is_empty() || rows.len() > 6 {
285        return fail(path, ErrorKind::LimitExceeded);
286    }
287    let mut artifacts: Vec<ReleaseArtifact> = Vec::with_capacity(rows.len());
288    for (index, row) in rows.into_iter().enumerate() {
289        artifacts.push(decode_artifact(&format!("{path}[{index}]"), row)?);
290    }
291    sorted_unique(path, &artifacts, |a, b| {
292        a.platform.as_str().cmp(b.platform.as_str())
293    })?;
294    Ok(artifacts)
295}
296
297fn decode_artifact(path: &str, value: Value) -> Result<ReleaseArtifact, Error> {
298    let mut obj = Obj::new(path, value)?;
299    let platform = ConstraintPlatform::decode(&obj.field("platform"), obj.take("platform")?)?;
300    let artifact_name =
301        decode_artifact_id(&obj.field("artifact_name"), obj.take("artifact_name")?)?;
302    let tree_path = decode_repo_path(&obj.field("tree_path"), obj.take("tree_path")?)?;
303    let binary_sha256 = decode_digest(&obj.field("binary_sha256"), obj.take("binary_sha256")?)?;
304    let engine_digest = decode_digest(&obj.field("engine_digest"), obj.take("engine_digest")?)?;
305    de::const_str(
306        &obj.field("runtime_contract"),
307        obj.take("runtime_contract")?,
308        RUNTIME_CONTRACT,
309    )?;
310    de::const_str(
311        &obj.field("environment_contract"),
312        obj.take("environment_contract")?,
313        ENVIRONMENT_CONTRACT,
314    )?;
315    let files_path = obj.field("runtime_files");
316    let runtime_files = decode_runtime_files(&files_path, obj.take("runtime_files")?)?;
317    obj.finish()?;
318    let artifact = ReleaseArtifact {
319        platform,
320        artifact_name,
321        tree_path,
322        binary_sha256,
323        engine_digest,
324        runtime_files,
325    };
326    if artifact.executable().is_none() || artifact.launcher().is_none() {
327        return fail(&files_path, ErrorKind::Inconsistent);
328    }
329    Ok(artifact)
330}
331
332fn decode_runtime_files(path: &str, value: Value) -> Result<Vec<RuntimeFile>, Error> {
333    let rows = de::array(path, value)?;
334    if rows.is_empty() || rows.len() > 256 {
335        return fail(path, ErrorKind::LimitExceeded);
336    }
337    let mut files: Vec<RuntimeFile> = Vec::with_capacity(rows.len());
338    for (index, row) in rows.into_iter().enumerate() {
339        let row_path = format!("{path}[{index}]");
340        let mut file = Obj::new(&row_path, row)?;
341        let member = decode_repo_path(&file.field("path"), file.take("path")?)?;
342        let role = RuntimeRole::decode(&file.field("role"), file.take("role")?)?;
343        let mode_path = file.field("git_mode");
344        let git_mode = match de::string(&mode_path, file.take("git_mode")?)?.as_str() {
345            "100644" => GitMode::RegularFile,
346            "100755" => GitMode::ExecutableFile,
347            _ => return fail(&mode_path, ErrorKind::InvalidValue),
348        };
349        let file_sha256 = decode_digest(&file.field("file_sha256"), file.take("file_sha256")?)?;
350        file.finish()?;
351        files.push(RuntimeFile {
352            path: member,
353            role,
354            git_mode,
355            file_sha256,
356        });
357    }
358    sorted_unique(path, &files, |a, b| a.path.as_str().cmp(b.path.as_str()))?;
359    Ok(files)
360}
361
362fn sorted_unique<T>(
363    path: &str,
364    items: &[T],
365    compare: impl Fn(&T, &T) -> Ordering,
366) -> Result<(), Error> {
367    for pair in items.windows(2) {
368        if let [left, right] = pair
369            && compare(left, right) != Ordering::Less
370        {
371            return fail(path, ErrorKind::UnsortedSet);
372        }
373    }
374    Ok(())
375}
376
377fn decode_digest(path: &str, value: Value) -> Result<Digest, Error> {
378    let raw = de::string(path, value)?;
379    Digest::from_wire(&raw).ok_or_else(|| Error::new(path, ErrorKind::InvalidValue))
380}
381
382fn decode_repo_path(path: &str, value: Value) -> Result<RepoPath, Error> {
383    RepoPath::new(de::string(path, value)?).ok_or_else(|| Error::new(path, ErrorKind::InvalidValue))
384}
385
386fn decode_artifact_id(path: &str, value: Value) -> Result<ArtifactId, Error> {
387    ArtifactId::new(de::string(path, value)?)
388        .ok_or_else(|| Error::new(path, ErrorKind::InvalidValue))
389}