Skip to main content

gibblox_pipeline/
lib.rs

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}