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#[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#[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#[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#[derive(Clone, Debug, PartialEq, Eq)]
78pub struct DependencyLockInput {
79 pub files: Vec<(RepoPath, Digest)>,
80}
81
82#[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 pub fn parse(bytes: &[u8]) -> Result<Self, Error> {
104 Self::decode(root(bytes)?)
105 }
106
107 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 #[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 #[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 #[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}