1#![cfg_attr(not(any(feature = "std", feature = "web")), no_std)]
2
3extern crate alloc;
4
5#[cfg(test)]
6extern crate std;
7
8use alloc::{boxed::Box, string::String, string::ToString, vec::Vec};
9use core::fmt;
10
11use gibblox_core::{BlockReaderConfigIdentity, config_identity_string, derive_config_identity_id};
12use serde::{Deserialize, Serialize};
13
14use crate::bin::{
15 PIPELINE_BIN_FORMAT_VERSION, PIPELINE_BIN_HEADER_LEN, PIPELINE_BIN_MAGIC,
16 PIPELINE_BIN_SUPPORTED_VERSIONS, PipelineSourceBin,
17};
18
19pub use gibblox_schema::bin::{
20 PIPELINE_HINTS_BIN_FORMAT_VERSION, PIPELINE_HINTS_BIN_HEADER_LEN, PIPELINE_HINTS_BIN_MAGIC,
21};
22pub use gibblox_schema::{
23 PipelineAndroidSparseChunkIndexHint, PipelineAndroidSparseIndexHint, PipelineContentDigestHint,
24 PipelineHint, PipelineHintEntry, PipelineHints, PipelineHintsCodecError,
25 PipelineHintsValidationError, PipelineTarEntryIndexHint, decode_pipeline_hints,
26 decode_pipeline_hints_prefix, encode_pipeline_hints, pipeline_hints_bin_header_version,
27 validate_pipeline_hints,
28};
29
30pub mod bin;
31
32#[cfg(any(feature = "std", all(feature = "web", target_arch = "wasm32")))]
33mod materialize_common;
34
35#[cfg(feature = "std")]
36mod materialize_std;
37
38#[cfg(all(feature = "web", target_arch = "wasm32"))]
39mod materialize_web;
40
41#[cfg(feature = "std")]
42pub use materialize_std::{OpenPipelineOptions, open_pipeline};
43
44#[cfg(all(feature = "web", target_arch = "wasm32"))]
45pub use materialize_web::{
46 OpenPipelineOptions as OpenWebPipelineOptions, open_pipeline as open_pipeline_web,
47};
48
49#[derive(Debug)]
50pub enum PipelineCodecError {
51 Decode(postcard::Error),
52 InvalidMagic,
53 UnsupportedFormatVersion(u16),
54}
55
56impl fmt::Display for PipelineCodecError {
57 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58 match self {
59 Self::Decode(err) => write!(f, "decode pipeline: {err}"),
60 Self::InvalidMagic => {
61 write!(
62 f,
63 "invalid pipeline magic (expected {PIPELINE_BIN_MAGIC:?})"
64 )
65 }
66 Self::UnsupportedFormatVersion(version) => write!(
67 f,
68 "unsupported pipeline format version {version} (supported {PIPELINE_BIN_SUPPORTED_VERSIONS:?})"
69 ),
70 }
71 }
72}
73
74#[cfg(any(feature = "std", feature = "web"))]
75impl std::error::Error for PipelineCodecError {}
76
77impl From<postcard::Error> for PipelineCodecError {
78 fn from(err: postcard::Error) -> Self {
79 Self::Decode(err)
80 }
81}
82
83pub fn decode_pipeline(bytes: &[u8]) -> Result<PipelineSource, PipelineCodecError> {
84 let Some(format_version) = pipeline_bin_header_version(bytes) else {
85 return Err(PipelineCodecError::InvalidMagic);
86 };
87
88 let payload = &bytes[PIPELINE_BIN_HEADER_LEN..];
89 if !PIPELINE_BIN_SUPPORTED_VERSIONS.contains(&format_version) {
90 return Err(PipelineCodecError::UnsupportedFormatVersion(format_version));
91 }
92 let pipeline = postcard::from_bytes::<PipelineSourceBin>(payload)?;
93 Ok(PipelineSource::from(pipeline))
94}
95
96pub fn encode_pipeline(source: &PipelineSource) -> Result<Vec<u8>, postcard::Error> {
97 let payload = postcard::to_allocvec(&PipelineSourceBin::from(source.clone()))?;
98 let mut out = Vec::with_capacity(PIPELINE_BIN_HEADER_LEN + payload.len());
99 out.extend_from_slice(&PIPELINE_BIN_MAGIC);
100 out.extend_from_slice(&PIPELINE_BIN_FORMAT_VERSION.to_le_bytes());
101 out.extend_from_slice(&payload);
102 Ok(out)
103}
104
105pub fn pipeline_bin_header_version(bytes: &[u8]) -> Option<u16> {
106 if bytes.len() < PIPELINE_BIN_HEADER_LEN {
107 return None;
108 }
109 if bytes[..PIPELINE_BIN_MAGIC.len()] != PIPELINE_BIN_MAGIC {
110 return None;
111 }
112 Some(u16::from_le_bytes([
113 bytes[PIPELINE_BIN_MAGIC.len()],
114 bytes[PIPELINE_BIN_MAGIC.len() + 1],
115 ]))
116}
117
118pub fn pipeline_identity_string(source: &PipelineSource) -> String {
119 config_identity_string(source)
120}
121
122pub fn pipeline_identity_id(source: &PipelineSource) -> u32 {
123 derive_config_identity_id(source)
124}
125
126#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
127pub enum PipelineCachePolicy {
128 #[default]
129 None,
130 Head,
131 Tail,
132}
133
134impl PipelineCachePolicy {
135 pub const fn as_str(self) -> &'static str {
136 match self {
137 Self::None => "none",
138 Self::Head => "head",
139 Self::Tail => "tail",
140 }
141 }
142}
143
144impl fmt::Display for PipelineCachePolicy {
145 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
146 f.write_str(self.as_str())
147 }
148}
149
150#[derive(Clone, Debug, PartialEq, Eq)]
151pub enum PipelineValidationError {
152 EmptyHttp,
153 EmptyFile,
154 EmptyCasyncIndex,
155 EmptyCasyncChunkStore,
156 EmptyTarEntry,
157 MissingHttpContent,
158 MissingFileContent,
159 MissingCasyncContent,
160 EmptyContentDigest,
161 InvalidContentDigestPrefix { digest: String },
162 InvalidContentDigestLength { digest: String, hex_len: usize },
163 InvalidContentDigestHex { digest: String },
164 UnsupportedCasyncArchiveIndex { index: String },
165 PipelineDepthExceeded { max_depth: usize },
166 InvalidMbrSelectorCount { selectors: usize },
167 EmptyMbrPartuuid,
168 InvalidGptSelectorCount { selectors: usize },
169 EmptyGptPartlabel,
170 EmptyGptPartuuid,
171}
172
173impl fmt::Display for PipelineValidationError {
174 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
175 match self {
176 Self::EmptyHttp => write!(f, "pipeline http source must not be empty"),
177 Self::EmptyFile => write!(f, "pipeline file source must not be empty"),
178 Self::EmptyCasyncIndex => write!(f, "pipeline casync.index source must not be empty"),
179 Self::EmptyCasyncChunkStore => {
180 write!(f, "pipeline casync.chunk_store source must not be empty")
181 }
182 Self::EmptyTarEntry => write!(f, "pipeline tar.entry must not be empty"),
183 Self::MissingHttpContent => {
184 write!(f, "pipeline http source must include content metadata")
185 }
186 Self::MissingFileContent => {
187 write!(f, "pipeline file source must include content metadata")
188 }
189 Self::MissingCasyncContent => {
190 write!(f, "pipeline casync source must include content metadata")
191 }
192 Self::EmptyContentDigest => {
193 write!(f, "pipeline content digest must not be empty")
194 }
195 Self::InvalidContentDigestPrefix { digest } => write!(
196 f,
197 "pipeline content digest must use 'sha512:' prefix, got '{digest}'"
198 ),
199 Self::InvalidContentDigestLength { digest, hex_len } => write!(
200 f,
201 "pipeline content digest '{digest}' must include exactly 128 lowercase hex chars, got {hex_len}"
202 ),
203 Self::InvalidContentDigestHex { digest } => write!(
204 f,
205 "pipeline content digest '{digest}' must contain only lowercase hex chars"
206 ),
207 Self::UnsupportedCasyncArchiveIndex { index } => write!(
208 f,
209 "unsupported casync archive index (.caidx) in pipeline: {index}; expected casync blob index (.caibx)"
210 ),
211 Self::PipelineDepthExceeded { max_depth } => {
212 write!(f, "pipeline exceeds max depth {max_depth}")
213 }
214 Self::InvalidMbrSelectorCount { selectors } => write!(
215 f,
216 "pipeline mbr step must specify exactly one selector (partuuid or index); found {selectors}"
217 ),
218 Self::EmptyMbrPartuuid => write!(f, "pipeline mbr partuuid must not be empty"),
219 Self::InvalidGptSelectorCount { selectors } => write!(
220 f,
221 "pipeline gpt step must specify exactly one selector (partlabel, partuuid, or index); found {selectors}"
222 ),
223 Self::EmptyGptPartlabel => write!(f, "pipeline gpt partlabel must not be empty"),
224 Self::EmptyGptPartuuid => write!(f, "pipeline gpt partuuid must not be empty"),
225 }
226 }
227}
228
229#[cfg(any(feature = "std", feature = "web"))]
230impl std::error::Error for PipelineValidationError {}
231
232pub const MAX_PIPELINE_DEPTH: usize = 16;
233
234pub fn validate_pipeline(source: &PipelineSource) -> Result<(), PipelineValidationError> {
235 validate_pipeline_source(source, 0)
236}
237
238fn validate_pipeline_source(
239 source: &PipelineSource,
240 depth: usize,
241) -> Result<(), PipelineValidationError> {
242 if depth > MAX_PIPELINE_DEPTH {
243 return Err(PipelineValidationError::PipelineDepthExceeded {
244 max_depth: MAX_PIPELINE_DEPTH,
245 });
246 }
247
248 match source {
249 PipelineSource::Http(source) => {
250 if source.http.trim().is_empty() {
251 return Err(PipelineValidationError::EmptyHttp);
252 }
253 let Some(content) = source.content.as_ref() else {
254 return Err(PipelineValidationError::MissingHttpContent);
255 };
256 validate_pipeline_content(content)?;
257 Ok(())
258 }
259 PipelineSource::File(source) => {
260 if source.file.trim().is_empty() {
261 return Err(PipelineValidationError::EmptyFile);
262 }
263 let Some(content) = source.content.as_ref() else {
264 return Err(PipelineValidationError::MissingFileContent);
265 };
266 validate_pipeline_content(content)?;
267 Ok(())
268 }
269 PipelineSource::Casync(source) => {
270 let index = source.casync.index.trim();
271 if index.is_empty() {
272 return Err(PipelineValidationError::EmptyCasyncIndex);
273 }
274
275 let index_path = strip_query_and_fragment(index);
276 if index_path.to_ascii_lowercase().ends_with(".caidx") {
277 return Err(PipelineValidationError::UnsupportedCasyncArchiveIndex {
278 index: source.casync.index.clone(),
279 });
280 }
281
282 if let Some(chunk_store) = source.casync.chunk_store.as_deref() {
283 if chunk_store.trim().is_empty() {
284 return Err(PipelineValidationError::EmptyCasyncChunkStore);
285 }
286 }
287 let Some(content) = source.casync.content.as_ref() else {
288 return Err(PipelineValidationError::MissingCasyncContent);
289 };
290 validate_pipeline_content(content)?;
291 Ok(())
292 }
293 PipelineSource::Xz(source) => {
294 if let Some(content) = source.content.as_ref() {
295 validate_pipeline_content(content)?;
296 }
297 validate_pipeline_source(source.xz.as_ref(), depth + 1)
298 }
299 PipelineSource::Tar(source) => {
300 if source.tar.entry.trim().is_empty() {
301 return Err(PipelineValidationError::EmptyTarEntry);
302 }
303 if let Some(content) = source.tar.content.as_ref() {
304 validate_pipeline_content(content)?;
305 }
306 validate_pipeline_source(source.tar.source.as_ref(), depth + 1)
307 }
308 PipelineSource::AndroidSparseImg(source) => {
309 if let Some(content) = source.android_sparseimg.content.as_ref() {
310 validate_pipeline_content(content)?;
311 }
312 validate_pipeline_source(source.android_sparseimg.source.as_ref(), depth + 1)
313 }
314 PipelineSource::Mbr(source) => {
315 if let Some(content) = source.mbr.content.as_ref() {
316 validate_pipeline_content(content)?;
317 }
318 let mut selectors = 0usize;
319 if let Some(partuuid) = source.mbr.partuuid.as_deref() {
320 if partuuid.trim().is_empty() {
321 return Err(PipelineValidationError::EmptyMbrPartuuid);
322 }
323 selectors += 1;
324 }
325 if source.mbr.index.is_some() {
326 selectors += 1;
327 }
328 if selectors != 1 {
329 return Err(PipelineValidationError::InvalidMbrSelectorCount { selectors });
330 }
331 validate_pipeline_source(source.mbr.source.as_ref(), depth + 1)
332 }
333 PipelineSource::Gpt(source) => {
334 if let Some(content) = source.gpt.content.as_ref() {
335 validate_pipeline_content(content)?;
336 }
337 let mut selectors = 0usize;
338 if let Some(partlabel) = source.gpt.partlabel.as_deref() {
339 if partlabel.trim().is_empty() {
340 return Err(PipelineValidationError::EmptyGptPartlabel);
341 }
342 selectors += 1;
343 }
344 if let Some(partuuid) = source.gpt.partuuid.as_deref() {
345 if partuuid.trim().is_empty() {
346 return Err(PipelineValidationError::EmptyGptPartuuid);
347 }
348 selectors += 1;
349 }
350 if source.gpt.index.is_some() {
351 selectors += 1;
352 }
353 if selectors != 1 {
354 return Err(PipelineValidationError::InvalidGptSelectorCount { selectors });
355 }
356 validate_pipeline_source(source.gpt.source.as_ref(), depth + 1)
357 }
358 }
359}
360
361fn validate_pipeline_content(
362 content: &PipelineSourceContent,
363) -> Result<(), PipelineValidationError> {
364 let digest = content.digest.trim();
365 if digest.is_empty() {
366 return Err(PipelineValidationError::EmptyContentDigest);
367 }
368
369 let Some(hex) = digest.strip_prefix("sha512:") else {
370 return Err(PipelineValidationError::InvalidContentDigestPrefix {
371 digest: digest.to_string(),
372 });
373 };
374
375 if hex.len() != 128 {
376 return Err(PipelineValidationError::InvalidContentDigestLength {
377 digest: digest.to_string(),
378 hex_len: hex.len(),
379 });
380 }
381
382 if !hex
383 .bytes()
384 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
385 {
386 return Err(PipelineValidationError::InvalidContentDigestHex {
387 digest: digest.to_string(),
388 });
389 }
390
391 Ok(())
392}
393
394fn strip_query_and_fragment(value: &str) -> &str {
395 let mut end = value.len();
396 if let Some(pos) = value.find('?') {
397 end = end.min(pos);
398 }
399 if let Some(pos) = value.find('#') {
400 end = end.min(pos);
401 }
402 &value[..end]
403}
404
405impl BlockReaderConfigIdentity for PipelineSource {
406 fn write_identity(&self, out: &mut dyn fmt::Write) -> fmt::Result {
407 write_pipeline_identity(self, out)
408 }
409}
410
411fn write_pipeline_identity(source: &PipelineSource, out: &mut dyn fmt::Write) -> fmt::Result {
412 match source {
413 PipelineSource::Http(source) => {
414 out.write_str("http{")?;
415 write_string_field(out, "url", source.http.as_str())?;
416 write_bool_field(out, "cors_safelisted_mode", source.cors_safelisted_mode)?;
417 out.write_str("}")
418 }
419 PipelineSource::File(source) => {
420 out.write_str("file{")?;
421 write_string_field(out, "path", source.file.as_str())?;
422 out.write_str("}")
423 }
424 PipelineSource::Casync(source) => {
425 out.write_str("casync{")?;
426 write_string_field(out, "index", source.casync.index.as_str())?;
427 write_opt_string_field(out, "chunk_store", source.casync.chunk_store.as_deref())?;
428 out.write_str("}")
429 }
430 PipelineSource::Xz(source) => {
431 out.write_str("xz{source=")?;
432 write_pipeline_identity(source.xz.as_ref(), out)?;
433 out.write_str("}")
434 }
435 PipelineSource::Tar(source) => {
436 out.write_str("tar{")?;
437 write_string_field(out, "entry", source.tar.entry.as_str())?;
438 out.write_str("source=")?;
439 write_pipeline_identity(source.tar.source.as_ref(), out)?;
440 out.write_str("}")
441 }
442 PipelineSource::AndroidSparseImg(source) => {
443 out.write_str("android_sparseimg{source=")?;
444 write_pipeline_identity(source.android_sparseimg.source.as_ref(), out)?;
445 out.write_str("}")
446 }
447 PipelineSource::Mbr(source) => {
448 out.write_str("mbr{")?;
449 write_opt_string_field(out, "partuuid", source.mbr.partuuid.as_deref())?;
450 write_opt_u32_field(out, "index", source.mbr.index)?;
451 write_opt_u32_field(out, "lba_size", source.mbr.lba_size)?;
452 out.write_str("source=")?;
453 write_pipeline_identity(source.mbr.source.as_ref(), out)?;
454 out.write_str("}")
455 }
456 PipelineSource::Gpt(source) => {
457 out.write_str("gpt{")?;
458 write_opt_string_field(out, "partlabel", source.gpt.partlabel.as_deref())?;
459 write_opt_string_field(out, "partuuid", source.gpt.partuuid.as_deref())?;
460 write_opt_u32_field(out, "index", source.gpt.index)?;
461 write_opt_u32_field(out, "lba_size", source.gpt.lba_size)?;
462 out.write_str("source=")?;
463 write_pipeline_identity(source.gpt.source.as_ref(), out)?;
464 out.write_str("}")
465 }
466 }
467}
468
469fn write_string_field(out: &mut dyn fmt::Write, key: &str, value: &str) -> fmt::Result {
470 write!(out, "{key}=len:{}:", value.len())?;
471 out.write_str(value)?;
472 out.write_str(";")
473}
474
475fn write_opt_string_field(out: &mut dyn fmt::Write, key: &str, value: Option<&str>) -> fmt::Result {
476 match value {
477 Some(value) => {
478 out.write_str(key)?;
479 out.write_str("=some:")?;
480 write_string_field(out, "value", value)
481 }
482 None => {
483 out.write_str(key)?;
484 out.write_str("=none;")
485 }
486 }
487}
488
489fn write_opt_u32_field(out: &mut dyn fmt::Write, key: &str, value: Option<u32>) -> fmt::Result {
490 match value {
491 Some(value) => {
492 out.write_str(key)?;
493 write!(out, "=some:{value};")
494 }
495 None => {
496 out.write_str(key)?;
497 out.write_str("=none;")
498 }
499 }
500}
501
502fn write_bool_field(out: &mut dyn fmt::Write, key: &str, value: bool) -> fmt::Result {
503 write!(out, "{key}={value};")
504}
505
506#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
507#[serde(untagged)]
508pub enum PipelineSource {
509 Casync(PipelineSourceCasyncSource),
510 Http(PipelineSourceHttpSource),
511 File(PipelineSourceFileSource),
512 Xz(PipelineSourceXzSource),
513 Tar(PipelineSourceTarSource),
514 AndroidSparseImg(PipelineSourceAndroidSparseImgSource),
515 Mbr(PipelineSourceMbrSource),
516 Gpt(PipelineSourceGptSource),
517}
518
519#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
520#[serde(deny_unknown_fields)]
521pub struct PipelineSourceHttpSource {
522 pub http: String,
523 #[serde(default, skip_serializing_if = "is_false")]
524 pub cors_safelisted_mode: bool,
525 #[serde(default, skip_serializing_if = "Option::is_none")]
526 pub content: Option<PipelineSourceContent>,
527}
528
529fn is_false(value: &bool) -> bool {
530 !*value
531}
532
533#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
534#[serde(deny_unknown_fields)]
535pub struct PipelineSourceFileSource {
536 pub file: String,
537 #[serde(default, skip_serializing_if = "Option::is_none")]
538 pub content: Option<PipelineSourceContent>,
539}
540
541#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
542#[serde(deny_unknown_fields)]
543pub struct PipelineSourceCasyncSource {
544 #[serde(deserialize_with = "deserialize_casync_source")]
545 pub casync: PipelineSourceCasync,
546}
547
548#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
549#[serde(untagged)]
550enum PipelineSourceCasyncValue {
551 Index(String),
552 Source(PipelineSourceCasync),
553}
554
555fn deserialize_casync_source<'de, D>(deserializer: D) -> Result<PipelineSourceCasync, D::Error>
556where
557 D: serde::Deserializer<'de>,
558{
559 let value = PipelineSourceCasyncValue::deserialize(deserializer)?;
560 Ok(match value {
561 PipelineSourceCasyncValue::Index(index) => PipelineSourceCasync {
562 index,
563 chunk_store: None,
564 content: None,
565 },
566 PipelineSourceCasyncValue::Source(source) => source,
567 })
568}
569
570#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
571#[serde(deny_unknown_fields)]
572pub struct PipelineSourceCasync {
573 pub index: String,
574 #[serde(default, skip_serializing_if = "Option::is_none")]
575 pub chunk_store: Option<String>,
576 #[serde(default, skip_serializing_if = "Option::is_none")]
577 pub content: Option<PipelineSourceContent>,
578}
579
580#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
581#[serde(deny_unknown_fields)]
582pub struct PipelineSourceContent {
583 pub digest: String,
584 pub size_bytes: u64,
585}
586
587#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
588#[serde(deny_unknown_fields)]
589pub struct PipelineSourceXzSource {
590 #[serde(deserialize_with = "deserialize_nested_pipeline_source")]
591 pub xz: Box<PipelineSource>,
592 #[serde(default, skip_serializing_if = "Option::is_none")]
593 pub content: Option<PipelineSourceContent>,
594}
595
596#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
597#[serde(deny_unknown_fields)]
598pub struct PipelineSourceTarSource {
599 #[serde(deserialize_with = "deserialize_tar_source")]
600 pub tar: PipelineSourceTar,
601}
602
603#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
604pub struct PipelineSourceTar {
605 pub entry: String,
606 #[serde(flatten)]
607 pub source: Box<PipelineSource>,
608 #[serde(default, skip_serializing_if = "Option::is_none")]
609 pub content: Option<PipelineSourceContent>,
610}
611
612#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
613#[serde(deny_unknown_fields)]
614pub struct PipelineSourceAndroidSparseImgSource {
615 #[serde(deserialize_with = "deserialize_android_sparseimg_source")]
616 pub android_sparseimg: PipelineSourceAndroidSparseImg,
617}
618
619#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
620pub struct PipelineSourceAndroidSparseImg {
621 #[serde(flatten)]
622 pub source: Box<PipelineSource>,
623 #[serde(default, skip_serializing_if = "Option::is_none")]
624 pub content: Option<PipelineSourceContent>,
625}
626
627#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
628#[serde(untagged)]
629enum NestedPipelineSourceValue {
630 Direct(Box<PipelineSource>),
631 Source { source: Box<PipelineSource> },
632}
633
634fn deserialize_nested_pipeline_source<'de, D>(
635 deserializer: D,
636) -> Result<Box<PipelineSource>, D::Error>
637where
638 D: serde::Deserializer<'de>,
639{
640 let value = NestedPipelineSourceValue::deserialize(deserializer)?;
641 Ok(match value {
642 NestedPipelineSourceValue::Direct(source) => source,
643 NestedPipelineSourceValue::Source { source } => source,
644 })
645}
646
647#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
648#[serde(untagged)]
649enum PipelineSourceTarValue {
650 Flatten {
651 entry: String,
652 #[serde(flatten)]
653 source: Box<PipelineSource>,
654 #[serde(default)]
655 content: Option<PipelineSourceContent>,
656 },
657 Source {
658 entry: String,
659 source: Box<PipelineSource>,
660 #[serde(default)]
661 content: Option<PipelineSourceContent>,
662 },
663}
664
665fn deserialize_tar_source<'de, D>(deserializer: D) -> Result<PipelineSourceTar, D::Error>
666where
667 D: serde::Deserializer<'de>,
668{
669 let value = PipelineSourceTarValue::deserialize(deserializer)?;
670 Ok(match value {
671 PipelineSourceTarValue::Flatten {
672 entry,
673 source,
674 content,
675 }
676 | PipelineSourceTarValue::Source {
677 entry,
678 source,
679 content,
680 } => PipelineSourceTar {
681 entry,
682 source,
683 content,
684 },
685 })
686}
687
688#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
689#[serde(untagged)]
690enum PipelineSourceAndroidSparseImgValue {
691 Flatten {
692 #[serde(flatten)]
693 source: Box<PipelineSource>,
694 #[serde(default)]
695 content: Option<PipelineSourceContent>,
696 },
697 Source {
698 source: Box<PipelineSource>,
699 #[serde(default)]
700 content: Option<PipelineSourceContent>,
701 },
702}
703
704fn deserialize_android_sparseimg_source<'de, D>(
705 deserializer: D,
706) -> Result<PipelineSourceAndroidSparseImg, D::Error>
707where
708 D: serde::Deserializer<'de>,
709{
710 let value = PipelineSourceAndroidSparseImgValue::deserialize(deserializer)?;
711 Ok(match value {
712 PipelineSourceAndroidSparseImgValue::Flatten { source, content }
713 | PipelineSourceAndroidSparseImgValue::Source { source, content } => {
714 PipelineSourceAndroidSparseImg { source, content }
715 }
716 })
717}
718
719#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
720#[serde(deny_unknown_fields)]
721pub struct PipelineSourceMbrSource {
722 #[serde(deserialize_with = "deserialize_mbr_source")]
723 pub mbr: PipelineSourceMbr,
724}
725
726#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
727pub struct PipelineSourceMbr {
728 #[serde(default, skip_serializing_if = "Option::is_none")]
729 pub partuuid: Option<String>,
730 #[serde(default, skip_serializing_if = "Option::is_none")]
731 pub index: Option<u32>,
732 #[serde(default, skip_serializing_if = "Option::is_none")]
733 pub lba_size: Option<u32>,
734 #[serde(flatten)]
735 pub source: Box<PipelineSource>,
736 #[serde(default, skip_serializing_if = "Option::is_none")]
737 pub content: Option<PipelineSourceContent>,
738}
739
740#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
741#[serde(untagged)]
742enum PipelineSourceMbrValue {
743 Flatten {
744 #[serde(default)]
745 partuuid: Option<String>,
746 #[serde(default)]
747 index: Option<u32>,
748 #[serde(default)]
749 lba_size: Option<u32>,
750 #[serde(flatten)]
751 source: Box<PipelineSource>,
752 #[serde(default)]
753 content: Option<PipelineSourceContent>,
754 },
755 Source {
756 #[serde(default)]
757 partuuid: Option<String>,
758 #[serde(default)]
759 index: Option<u32>,
760 #[serde(default)]
761 lba_size: Option<u32>,
762 source: Box<PipelineSource>,
763 #[serde(default)]
764 content: Option<PipelineSourceContent>,
765 },
766}
767
768fn deserialize_mbr_source<'de, D>(deserializer: D) -> Result<PipelineSourceMbr, D::Error>
769where
770 D: serde::Deserializer<'de>,
771{
772 let value = PipelineSourceMbrValue::deserialize(deserializer)?;
773 Ok(match value {
774 PipelineSourceMbrValue::Flatten {
775 partuuid,
776 index,
777 lba_size,
778 source,
779 content,
780 }
781 | PipelineSourceMbrValue::Source {
782 partuuid,
783 index,
784 lba_size,
785 source,
786 content,
787 } => PipelineSourceMbr {
788 partuuid,
789 index,
790 lba_size,
791 source,
792 content,
793 },
794 })
795}
796
797#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
798#[serde(deny_unknown_fields)]
799pub struct PipelineSourceGptSource {
800 #[serde(deserialize_with = "deserialize_gpt_source")]
801 pub gpt: PipelineSourceGpt,
802}
803
804#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
805pub struct PipelineSourceGpt {
806 #[serde(default, skip_serializing_if = "Option::is_none")]
807 pub partlabel: Option<String>,
808 #[serde(default, skip_serializing_if = "Option::is_none")]
809 pub partuuid: Option<String>,
810 #[serde(default, skip_serializing_if = "Option::is_none")]
811 pub index: Option<u32>,
812 #[serde(default, skip_serializing_if = "Option::is_none")]
813 pub lba_size: Option<u32>,
814 #[serde(flatten)]
815 pub source: Box<PipelineSource>,
816 #[serde(default, skip_serializing_if = "Option::is_none")]
817 pub content: Option<PipelineSourceContent>,
818}
819
820#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
821#[serde(untagged)]
822enum PipelineSourceGptValue {
823 Flatten {
824 #[serde(default)]
825 partlabel: Option<String>,
826 #[serde(default)]
827 partuuid: Option<String>,
828 #[serde(default)]
829 index: Option<u32>,
830 #[serde(default)]
831 lba_size: Option<u32>,
832 #[serde(flatten)]
833 source: Box<PipelineSource>,
834 #[serde(default)]
835 content: Option<PipelineSourceContent>,
836 },
837 Source {
838 #[serde(default)]
839 partlabel: Option<String>,
840 #[serde(default)]
841 partuuid: Option<String>,
842 #[serde(default)]
843 index: Option<u32>,
844 #[serde(default)]
845 lba_size: Option<u32>,
846 source: Box<PipelineSource>,
847 #[serde(default)]
848 content: Option<PipelineSourceContent>,
849 },
850}
851
852fn deserialize_gpt_source<'de, D>(deserializer: D) -> Result<PipelineSourceGpt, D::Error>
853where
854 D: serde::Deserializer<'de>,
855{
856 let value = PipelineSourceGptValue::deserialize(deserializer)?;
857 Ok(match value {
858 PipelineSourceGptValue::Flatten {
859 partlabel,
860 partuuid,
861 index,
862 lba_size,
863 source,
864 content,
865 }
866 | PipelineSourceGptValue::Source {
867 partlabel,
868 partuuid,
869 index,
870 lba_size,
871 source,
872 content,
873 } => PipelineSourceGpt {
874 partlabel,
875 partuuid,
876 index,
877 lba_size,
878 source,
879 content,
880 },
881 })
882}
883
884#[cfg(test)]
885mod tests {
886 use alloc::{boxed::Box, string::String, vec::Vec};
887
888 use super::{
889 MAX_PIPELINE_DEPTH, PipelineCodecError, PipelineSource, PipelineSourceAndroidSparseImg,
890 PipelineSourceAndroidSparseImgSource, PipelineSourceCasync, PipelineSourceCasyncSource,
891 PipelineSourceContent, PipelineSourceGpt, PipelineSourceGptSource,
892 PipelineSourceHttpSource, PipelineSourceMbr, PipelineSourceMbrSource, PipelineSourceTar,
893 PipelineSourceTarSource, PipelineSourceXzSource, PipelineValidationError, decode_pipeline,
894 encode_pipeline, pipeline_bin_header_version, pipeline_identity_id,
895 pipeline_identity_string, validate_pipeline,
896 };
897
898 fn sample_content() -> PipelineSourceContent {
899 PipelineSourceContent {
900 digest: String::from(
901 "sha512:11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111",
902 ),
903 size_bytes: 123,
904 }
905 }
906
907 #[test]
908 fn validates_and_roundtrips_nested_pipeline() {
909 let source = PipelineSource::Gpt(PipelineSourceGptSource {
910 gpt: PipelineSourceGpt {
911 partlabel: None,
912 partuuid: Some(String::from("31b7f334-6df8-4f95-b4b0-c8653f8f8fbf")),
913 index: None,
914 lba_size: None,
915 source: Box::new(PipelineSource::AndroidSparseImg(
916 PipelineSourceAndroidSparseImgSource {
917 android_sparseimg: PipelineSourceAndroidSparseImg {
918 source: Box::new(PipelineSource::Xz(PipelineSourceXzSource {
919 xz: Box::new(PipelineSource::Http(PipelineSourceHttpSource {
920 http: String::from("https://cdn.example.invalid/device.img.xz"),
921 cors_safelisted_mode: false,
922 content: Some(sample_content()),
923 })),
924 content: None,
925 })),
926 content: None,
927 },
928 },
929 )),
930 content: None,
931 },
932 });
933
934 validate_pipeline(&source).expect("pipeline should validate");
935 let encoded = encode_pipeline(&source).expect("encode pipeline");
936 let decoded = decode_pipeline(&encoded).expect("decode pipeline");
937 assert_eq!(decoded, source);
938 }
939
940 #[test]
941 fn parses_source_style_yaml_pipeline() {
942 let source: PipelineSource = serde_yaml::from_str(
943 r#"
944gpt:
945 partlabel: rootfs
946 source:
947 android_sparseimg:
948 source:
949 xz:
950 source:
951 http: https://cdn.example.invalid/device.img.xz
952 content:
953 digest: sha512:11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111
954 size_bytes: 123
955"#,
956 )
957 .expect("parse source-style YAML");
958
959 validate_pipeline(&source).expect("source-style pipeline should validate");
960 match source {
961 PipelineSource::Gpt(source) => {
962 assert_eq!(source.gpt.partlabel.as_deref(), Some("rootfs"));
963 }
964 other => panic!("expected gpt source, got {other:?}"),
965 }
966 }
967
968 #[test]
969 fn validates_and_roundtrips_tar_pipeline() {
970 let source = PipelineSource::Tar(PipelineSourceTarSource {
971 tar: PipelineSourceTar {
972 entry: String::from("/rootfs.img"),
973 source: Box::new(PipelineSource::Xz(PipelineSourceXzSource {
974 xz: Box::new(PipelineSource::Http(PipelineSourceHttpSource {
975 http: String::from("https://cdn.example.invalid/rootfs.tar.xz"),
976 cors_safelisted_mode: false,
977 content: Some(sample_content()),
978 })),
979 content: None,
980 })),
981 content: None,
982 },
983 });
984
985 validate_pipeline(&source).expect("tar pipeline should validate");
986 let encoded = encode_pipeline(&source).expect("encode pipeline");
987 let decoded = decode_pipeline(&encoded).expect("decode pipeline");
988 assert_eq!(decoded, source);
989 }
990
991 #[test]
992 fn parses_tar_source_style_yaml_pipeline() {
993 let source: PipelineSource = serde_yaml::from_str(
994 r#"
995tar:
996 entry: /rootfs.img
997 source:
998 xz:
999 source:
1000 http: https://cdn.example.invalid/rootfs.tar.xz
1001 content:
1002 digest: sha512:11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111
1003 size_bytes: 123
1004"#,
1005 )
1006 .expect("parse tar source-style YAML");
1007
1008 validate_pipeline(&source).expect("tar source-style pipeline should validate");
1009 match source {
1010 PipelineSource::Tar(source) => {
1011 assert_eq!(source.tar.entry.as_str(), "/rootfs.img");
1012 }
1013 other => panic!("expected tar source, got {other:?}"),
1014 }
1015 }
1016
1017 #[test]
1018 fn rejects_casync_archive_index() {
1019 let source = PipelineSource::Casync(PipelineSourceCasyncSource {
1020 casync: PipelineSourceCasync {
1021 index: String::from("https://cdn.example.invalid/indexes/rootfs.caidx?x=1"),
1022 chunk_store: None,
1023 content: Some(sample_content()),
1024 },
1025 });
1026
1027 let err = validate_pipeline(&source).expect_err(".caidx should be rejected");
1028 assert!(matches!(
1029 err,
1030 PipelineValidationError::UnsupportedCasyncArchiveIndex { .. }
1031 ));
1032 }
1033
1034 #[test]
1035 fn rejects_mbr_with_no_selector() {
1036 let source = PipelineSource::Mbr(PipelineSourceMbrSource {
1037 mbr: PipelineSourceMbr {
1038 partuuid: None,
1039 index: None,
1040 lba_size: None,
1041 source: Box::new(PipelineSource::Http(PipelineSourceHttpSource {
1042 http: String::from("https://cdn.example.invalid/rootfs.img"),
1043 cors_safelisted_mode: false,
1044 content: Some(sample_content()),
1045 })),
1046 content: None,
1047 },
1048 });
1049
1050 let err = validate_pipeline(&source).expect_err("mbr selector cardinality should fail");
1051 assert_eq!(
1052 err,
1053 PipelineValidationError::InvalidMbrSelectorCount { selectors: 0 }
1054 );
1055 }
1056
1057 #[test]
1058 fn rejects_gpt_with_empty_partlabel() {
1059 let source = PipelineSource::Gpt(PipelineSourceGptSource {
1060 gpt: PipelineSourceGpt {
1061 partlabel: Some(String::from(" ")),
1062 partuuid: None,
1063 index: None,
1064 lba_size: None,
1065 source: Box::new(PipelineSource::Http(PipelineSourceHttpSource {
1066 http: String::from("https://cdn.example.invalid/rootfs.img"),
1067 cors_safelisted_mode: false,
1068 content: Some(sample_content()),
1069 })),
1070 content: None,
1071 },
1072 });
1073
1074 let err = validate_pipeline(&source).expect_err("blank partlabel should fail");
1075 assert_eq!(err, PipelineValidationError::EmptyGptPartlabel);
1076 }
1077
1078 #[test]
1079 fn rejects_depth_over_limit() {
1080 let mut source = PipelineSource::Http(PipelineSourceHttpSource {
1081 http: String::from("https://cdn.example.invalid/rootfs.img"),
1082 cors_safelisted_mode: false,
1083 content: Some(sample_content()),
1084 });
1085
1086 for _ in 0..=MAX_PIPELINE_DEPTH {
1087 source = PipelineSource::Xz(PipelineSourceXzSource {
1088 xz: Box::new(source),
1089 content: None,
1090 });
1091 }
1092
1093 let err = validate_pipeline(&source).expect_err("depth over max should fail");
1094 assert_eq!(
1095 err,
1096 PipelineValidationError::PipelineDepthExceeded {
1097 max_depth: MAX_PIPELINE_DEPTH,
1098 }
1099 );
1100 }
1101
1102 #[test]
1103 fn rejects_invalid_magic() {
1104 let err = decode_pipeline(b"not-a-pipeline").expect_err("invalid magic should fail");
1105 assert!(matches!(err, PipelineCodecError::InvalidMagic));
1106 }
1107
1108 #[test]
1109 fn rejects_unsupported_format_version() {
1110 let mut bytes = Vec::new();
1111 bytes.extend_from_slice(&crate::bin::PIPELINE_BIN_MAGIC);
1112 bytes.extend_from_slice(&99u16.to_le_bytes());
1113
1114 let err = decode_pipeline(&bytes).expect_err("unsupported version should fail");
1115 assert!(matches!(
1116 err,
1117 PipelineCodecError::UnsupportedFormatVersion(99)
1118 ));
1119 }
1120
1121 #[test]
1122 fn reads_header_version() {
1123 let source = PipelineSource::Http(PipelineSourceHttpSource {
1124 http: String::from("https://cdn.example.invalid/rootfs.img"),
1125 cors_safelisted_mode: false,
1126 content: Some(sample_content()),
1127 });
1128 let bytes = encode_pipeline(&source).expect("encode pipeline");
1129
1130 assert_eq!(pipeline_bin_header_version(&bytes), Some(0));
1131 }
1132
1133 #[test]
1134 fn identity_is_stable_for_same_descriptor() {
1135 let source = PipelineSource::Gpt(PipelineSourceGptSource {
1136 gpt: PipelineSourceGpt {
1137 partlabel: None,
1138 partuuid: Some(String::from("31b7f334-6df8-4f95-b4b0-c8653f8f8fbf")),
1139 index: None,
1140 lba_size: None,
1141 source: Box::new(PipelineSource::Xz(PipelineSourceXzSource {
1142 xz: Box::new(PipelineSource::Http(PipelineSourceHttpSource {
1143 http: String::from("https://cdn.example.invalid/device.img.xz"),
1144 cors_safelisted_mode: false,
1145 content: Some(sample_content()),
1146 })),
1147 content: None,
1148 })),
1149 content: None,
1150 },
1151 });
1152
1153 assert_eq!(
1154 pipeline_identity_string(&source),
1155 pipeline_identity_string(&source)
1156 );
1157 assert_eq!(pipeline_identity_id(&source), pipeline_identity_id(&source));
1158 }
1159
1160 #[test]
1161 fn identity_changes_when_descriptor_changes() {
1162 let a = PipelineSource::Http(PipelineSourceHttpSource {
1163 http: String::from("https://cdn.example.invalid/rootfs-a.img"),
1164 cors_safelisted_mode: false,
1165 content: Some(sample_content()),
1166 });
1167 let b = PipelineSource::Http(PipelineSourceHttpSource {
1168 http: String::from("https://cdn.example.invalid/rootfs-b.img"),
1169 cors_safelisted_mode: false,
1170 content: Some(sample_content()),
1171 });
1172
1173 assert_ne!(pipeline_identity_string(&a), pipeline_identity_string(&b));
1174 assert_ne!(pipeline_identity_id(&a), pipeline_identity_id(&b));
1175 }
1176
1177 #[test]
1178 fn identity_changes_when_cors_safelisted_mode_changes() {
1179 let strict = PipelineSource::Http(PipelineSourceHttpSource {
1180 http: String::from("https://cdn.example.invalid/rootfs.img"),
1181 cors_safelisted_mode: false,
1182 content: Some(sample_content()),
1183 });
1184 let safelisted = PipelineSource::Http(PipelineSourceHttpSource {
1185 http: String::from("https://cdn.example.invalid/rootfs.img"),
1186 cors_safelisted_mode: true,
1187 content: Some(sample_content()),
1188 });
1189
1190 assert_ne!(
1191 pipeline_identity_string(&strict),
1192 pipeline_identity_string(&safelisted)
1193 );
1194 assert_ne!(
1195 pipeline_identity_id(&strict),
1196 pipeline_identity_id(&safelisted)
1197 );
1198 }
1199
1200 #[test]
1201 fn rejects_http_without_content() {
1202 let source = PipelineSource::Http(PipelineSourceHttpSource {
1203 http: String::from("https://cdn.example.invalid/rootfs.img"),
1204 cors_safelisted_mode: false,
1205 content: None,
1206 });
1207
1208 let err = validate_pipeline(&source).expect_err("terminal content should be required");
1209 assert_eq!(err, PipelineValidationError::MissingHttpContent);
1210 }
1211
1212 #[test]
1213 fn accepts_missing_wrapper_content() {
1214 let source = PipelineSource::Xz(PipelineSourceXzSource {
1215 xz: Box::new(PipelineSource::Http(PipelineSourceHttpSource {
1216 http: String::from("https://cdn.example.invalid/rootfs.img"),
1217 cors_safelisted_mode: false,
1218 content: Some(sample_content()),
1219 })),
1220 content: None,
1221 });
1222
1223 validate_pipeline(&source).expect("wrapper content is optional");
1224 }
1225
1226 #[test]
1227 fn rejects_invalid_content_digest() {
1228 let source = PipelineSource::Http(PipelineSourceHttpSource {
1229 http: String::from("https://cdn.example.invalid/rootfs.img"),
1230 cors_safelisted_mode: false,
1231 content: Some(PipelineSourceContent {
1232 digest: String::from("sha512:NOTHEX"),
1233 size_bytes: 99,
1234 }),
1235 });
1236
1237 let err = validate_pipeline(&source).expect_err("digest must be lowercase hex");
1238 assert!(matches!(
1239 err,
1240 PipelineValidationError::InvalidContentDigestLength { .. }
1241 ));
1242 }
1243
1244 #[test]
1245 fn parses_http_cors_safelisted_mode() {
1246 let source: PipelineSource = serde_yaml::from_str(
1247 r#"
1248http: https://cdn.example.invalid/rootfs.img
1249cors_safelisted_mode: true
1250content:
1251 digest: sha512:11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111
1252 size_bytes: 123
1253"#,
1254 )
1255 .expect("parse http pipeline with cors safelisted mode");
1256
1257 match source {
1258 PipelineSource::Http(source) => {
1259 assert!(source.cors_safelisted_mode);
1260 }
1261 other => panic!("expected http source, got {other:?}"),
1262 }
1263 }
1264}