Skip to main content

update_package/
manifest.rs

1// Copyright 2025 The Fuchsia Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5//! Structs for parsing an OTA manifest.
6
7use crate::SystemVersion;
8use crate::update_mode::UpdateMode;
9use ota_manifest_proto::fuchsia::update::manifest as proto;
10use prost::Message as _;
11use std::convert::Infallible;
12use std::str::FromStr as _;
13
14/// The type of an image asset.
15pub type AssetType = proto::AssetType;
16
17/// Returns structured OTA manifest data based on raw file contents.
18pub fn parse_ota_manifest(contents: &[u8]) -> Result<OtaManifest, OtaManifestError> {
19    let manifest = proto::OtaManifest::decode(contents).map_err(OtaManifestError::ParseProto)?;
20    manifest.try_into().map_err(OtaManifestError::InvalidManifest)
21}
22
23/// An error encountered while parsing the OTA manifest.
24#[derive(Debug, thiserror::Error)]
25#[allow(missing_docs)]
26pub enum OtaManifestError {
27    #[error("while parsing proto")]
28    ParseProto(#[source] prost::DecodeError),
29
30    #[error("invalid proto manifest: {0}")]
31    InvalidManifest(String),
32}
33
34/// Information about a particular version of the OS.
35#[derive(Clone, Debug, PartialEq, Eq)]
36pub struct OtaManifest {
37    /// The version from the `build-info` of the target build. This field is for
38    /// informational purposes only and does not change the updater's behavior.
39    pub build_info_version: SystemVersion,
40    /// The board this OTA is for (e.g., "x64", "arm64"). The system updater will
41    /// reject the OTA if this does not match the device's expected board name
42    /// from `build-info`.
43    pub board: String,
44    /// The epoch of this OTA. See RFC-0071 for details.
45    pub epoch: u64,
46    /// The update mode, indicating if this is a normal update or a forced
47    /// recovery.
48    pub mode: UpdateMode,
49    /// The base URL prefix of the blobs, including the delivery blob type. The
50    /// final URL for each blob will be "{blob_base_url}/{fuchsia_merkle_root}".
51    /// Relative URLs are supported, and will be resolved relative to the URL of
52    /// the OTA manifest.
53    pub blob_base_url: String,
54    /// The partition images that should be written during the update.
55    pub images: Vec<Image>,
56    /// Additional blobs that should be written to blob storage.
57    pub blobs: Vec<Blob>,
58}
59
60impl OtaManifest {
61    /// Serializes the manifest to a byte vector using the protobuf encoding.
62    pub fn serialize(self) -> Vec<u8> {
63        let proto: proto::OtaManifest = self.into();
64        proto.encode_to_vec()
65    }
66}
67
68/// The target slot for an image.
69#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
70pub enum Slot {
71    /// The primary A/B slot.
72    #[default]
73    AB,
74    /// The recovery slot.
75    R,
76}
77
78/// An image to be written to a partition.
79#[derive(Clone, Debug, PartialEq, Eq)]
80pub struct Image {
81    /// The slot this image should be written to.
82    pub slot: Slot,
83    /// The type of the image.
84    pub image_type: ImageType,
85    /// The sha256 hash of the raw image with no padding.
86    pub sha256: fuchsia_hash::Sha256,
87    /// Metadata about the blob containing the image data.
88    pub blob: Blob,
89}
90
91impl Image {
92    /// Create a new `Image` from a file path.
93    pub fn from_path(
94        path: impl AsRef<std::path::Path>,
95        slot: Slot,
96        image_type: ImageType,
97    ) -> Result<Self, std::io::Error> {
98        use sha2::Digest as _;
99        use std::io::Read as _;
100
101        let mut file = std::fs::File::open(path)?;
102        let mut sha256_hasher = sha2::Sha256::new();
103        let mut merkle_builder = fuchsia_merkle::BufferedMerkleRootBuilder::default();
104        let mut buf = [0; 65536];
105        let mut size = 0;
106        loop {
107            let bytes_read = file.read(&mut buf)?;
108            if bytes_read == 0 {
109                break;
110            }
111            let buf = &buf[..bytes_read];
112            sha256_hasher.update(buf);
113            merkle_builder.write(buf);
114            size += bytes_read as u64;
115        }
116
117        let fuchsia_merkle_root = merkle_builder.complete();
118        let sha256 =
119            fuchsia_hash::Sha256::from(*AsRef::<[u8; 32]>::as_ref(&sha256_hasher.finalize()));
120
121        Ok(Self {
122            slot,
123            image_type,
124            sha256,
125            blob: Blob { uncompressed_size: size, fuchsia_merkle_root },
126        })
127    }
128}
129
130/// The type of the image.
131#[derive(Clone, Debug, PartialEq, Eq)]
132pub enum ImageType {
133    /// A standard system asset like ZBI or VBMETA.
134    Asset(AssetType),
135    /// A firmware image, with the field value specifying the firmware type.
136    Firmware(String),
137}
138
139/// Metadata for a blob.
140#[derive(Clone, Debug, PartialEq, Eq)]
141pub struct Blob {
142    /// The uncompressed size of the blob in bytes.
143    pub uncompressed_size: u64,
144    /// The fuchsia merkle root of the uncompressed blob data.
145    pub fuchsia_merkle_root: fuchsia_hash::Hash,
146}
147
148impl TryFrom<proto::OtaManifest> for OtaManifest {
149    type Error = String;
150    fn try_from(proto: proto::OtaManifest) -> Result<Self, Self::Error> {
151        let mode = proto.mode().into();
152        let version: Result<_, Infallible> =
153            crate::SystemVersion::from_str(&proto.build_info_version);
154        Ok(Self {
155            build_info_version: version.unwrap(),
156            board: proto.board,
157            epoch: proto.epoch,
158            mode,
159            blob_base_url: proto.blob_base_url,
160            images: proto
161                .images
162                .into_iter()
163                .map(TryInto::try_into)
164                .collect::<Result<Vec<_>, _>>()?,
165            blobs: proto.blobs.into_iter().map(TryInto::try_into).collect::<Result<Vec<_>, _>>()?,
166        })
167    }
168}
169
170impl From<OtaManifest> for proto::OtaManifest {
171    fn from(manifest: OtaManifest) -> Self {
172        Self {
173            build_info_version: manifest.build_info_version.to_string(),
174            board: manifest.board,
175            epoch: manifest.epoch,
176            mode: proto::UpdateMode::from(manifest.mode).into(),
177            blob_base_url: manifest.blob_base_url,
178            images: manifest.images.into_iter().map(Into::into).collect(),
179            blobs: manifest.blobs.into_iter().map(Into::into).collect(),
180        }
181    }
182}
183
184impl From<proto::UpdateMode> for UpdateMode {
185    fn from(mode: proto::UpdateMode) -> Self {
186        match mode {
187            proto::UpdateMode::Normal => UpdateMode::Normal,
188            proto::UpdateMode::ForceRecovery => UpdateMode::ForceRecovery,
189        }
190    }
191}
192
193impl From<UpdateMode> for proto::UpdateMode {
194    fn from(mode: UpdateMode) -> Self {
195        match mode {
196            UpdateMode::Normal => proto::UpdateMode::Normal,
197            UpdateMode::ForceRecovery => proto::UpdateMode::ForceRecovery,
198        }
199    }
200}
201
202impl TryFrom<proto::Image> for Image {
203    type Error = String;
204    fn try_from(image: proto::Image) -> Result<Self, Self::Error> {
205        let slot = image.slot().into();
206        let image_type =
207            image.image_type.ok_or_else(|| "image_type missing".to_string())?.try_into()?;
208        let sha256 = fuchsia_hash::Sha256::from(
209            <[u8; 32]>::try_from(image.sha256)
210                .map_err(|e| format!("invalid sha256 length: {e:?}"))?,
211        );
212        let blob = image.blob.ok_or_else(|| "blob missing".to_string())?.try_into()?;
213        Ok(Self { slot, image_type, sha256, blob })
214    }
215}
216
217impl From<Image> for proto::Image {
218    fn from(image: Image) -> Self {
219        Self {
220            slot: proto::Slot::from(image.slot).into(),
221            image_type: Some(image.image_type.into()),
222            sha256: image.sha256.as_ref().to_vec(),
223            blob: Some(image.blob.into()),
224        }
225    }
226}
227
228impl From<proto::Slot> for Slot {
229    fn from(slot: proto::Slot) -> Self {
230        match slot {
231            proto::Slot::Ab => Slot::AB,
232            proto::Slot::R => Slot::R,
233        }
234    }
235}
236
237impl From<Slot> for proto::Slot {
238    fn from(slot: Slot) -> Self {
239        match slot {
240            Slot::AB => proto::Slot::Ab,
241            Slot::R => proto::Slot::R,
242        }
243    }
244}
245
246impl TryFrom<proto::image::ImageType> for ImageType {
247    type Error = String;
248    fn try_from(value: proto::image::ImageType) -> Result<Self, Self::Error> {
249        match value {
250            proto::image::ImageType::Asset(asset) => {
251                let asset_type = proto::AssetType::from_i32(asset)
252                    .ok_or_else(|| format!("unknown asset type: {asset}"))?;
253                Ok(ImageType::Asset(asset_type))
254            }
255            proto::image::ImageType::Firmware(firmware) => Ok(ImageType::Firmware(firmware)),
256        }
257    }
258}
259
260impl From<ImageType> for proto::image::ImageType {
261    fn from(image_type: ImageType) -> Self {
262        match image_type {
263            ImageType::Asset(asset) => proto::image::ImageType::Asset(asset.into()),
264            ImageType::Firmware(firmware) => proto::image::ImageType::Firmware(firmware),
265        }
266    }
267}
268
269impl From<Blob> for proto::Blob {
270    fn from(blob: Blob) -> Self {
271        Self {
272            uncompressed_size: blob.uncompressed_size,
273            fuchsia_merkle_root: blob.fuchsia_merkle_root.as_ref().to_vec(),
274        }
275    }
276}
277
278impl TryFrom<proto::Blob> for Blob {
279    type Error = String;
280    fn try_from(blob: proto::Blob) -> Result<Self, Self::Error> {
281        Ok(Self {
282            uncompressed_size: blob.uncompressed_size,
283            fuchsia_merkle_root: fuchsia_hash::Hash::from(
284                <[u8; 32]>::try_from(blob.fuchsia_merkle_root)
285                    .map_err(|e| format!("invalid merkle root length: {e:?}"))?,
286            ),
287        })
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use assert_matches::assert_matches;
295    use sha2::Digest as _;
296    use std::io::Write as _;
297    use std::str::FromStr;
298    use tempfile::NamedTempFile;
299
300    #[test]
301    fn test_parse_ota_manifest_success() {
302        let proto_manifest = proto::OtaManifest {
303            build_info_version: "1.2.3.4".to_string(),
304            board: "test-board".to_string(),
305            epoch: 1,
306            mode: proto::UpdateMode::Normal.into(),
307            blob_base_url: "http://example.com".to_string(),
308            images: vec![
309                proto::Image {
310                    slot: proto::Slot::Ab.into(),
311                    image_type: Some(proto::image::ImageType::Asset(proto::AssetType::Zbi.into())),
312                    sha256: vec![0; 32],
313                    blob: Some(proto::Blob {
314                        uncompressed_size: 1234,
315                        fuchsia_merkle_root: vec![1; 32],
316                    }),
317                },
318                proto::Image {
319                    slot: proto::Slot::Ab.into(),
320                    image_type: Some(proto::image::ImageType::Firmware("bootloader".to_string())),
321                    sha256: vec![2; 32],
322                    blob: Some(proto::Blob {
323                        uncompressed_size: 3456,
324                        fuchsia_merkle_root: vec![3; 32],
325                    }),
326                },
327            ],
328            blobs: vec![proto::Blob { uncompressed_size: 5678, fuchsia_merkle_root: vec![4; 32] }],
329        };
330        let buf = proto_manifest.encode_to_vec();
331
332        let manifest = parse_ota_manifest(&buf).unwrap();
333        assert_eq!(manifest.build_info_version, SystemVersion::from_str("1.2.3.4").unwrap());
334        assert_eq!(manifest.board, "test-board");
335        assert_eq!(manifest.epoch, 1);
336        assert_eq!(manifest.mode, UpdateMode::Normal);
337        assert_eq!(manifest.blob_base_url, "http://example.com");
338
339        assert_eq!(manifest.images.len(), 2);
340        assert_eq!(manifest.images[0].slot, Slot::AB);
341        assert_eq!(manifest.images[0].image_type, ImageType::Asset(AssetType::Zbi));
342        assert_eq!(manifest.images[0].sha256, [0; 32].into());
343        assert_eq!(manifest.images[0].blob.uncompressed_size, 1234);
344        assert_eq!(manifest.images[0].blob.fuchsia_merkle_root, [1; 32].into());
345
346        assert_eq!(manifest.images[1].slot, Slot::AB);
347        assert_eq!(manifest.images[1].image_type, ImageType::Firmware("bootloader".to_string()));
348        assert_eq!(manifest.images[1].sha256, [2; 32].into());
349        assert_eq!(manifest.images[1].blob.uncompressed_size, 3456);
350        assert_eq!(manifest.images[1].blob.fuchsia_merkle_root, [3; 32].into());
351
352        assert_eq!(manifest.blobs.len(), 1);
353        assert_eq!(manifest.blobs[0].uncompressed_size, 5678);
354        assert_eq!(manifest.blobs[0].fuchsia_merkle_root, [4; 32].into());
355    }
356
357    #[test]
358    fn test_parse_ota_manifest_invalid_proto() {
359        let err = parse_ota_manifest(b"invalid proto").unwrap_err();
360        assert_matches!(err, OtaManifestError::ParseProto(_));
361    }
362
363    #[test]
364    fn test_parse_ota_manifest_invalid_manifest() {
365        let proto_manifest = proto::OtaManifest {
366            build_info_version: "1.2.3.4".to_string(),
367            board: "test-board".to_string(),
368            epoch: 1,
369            mode: proto::UpdateMode::Normal.into(),
370            blob_base_url: "http://example.com".to_string(),
371            images: vec![proto::Image {
372                slot: proto::Slot::Ab.into(),
373                image_type: None,
374                sha256: vec![0; 32],
375                blob: Some(proto::Blob {
376                    uncompressed_size: 1234,
377                    fuchsia_merkle_root: vec![1; 32],
378                }),
379            }],
380            blobs: vec![],
381        };
382        let buf = proto_manifest.encode_to_vec();
383
384        let err = parse_ota_manifest(&buf).unwrap_err();
385        assert_matches!(err, OtaManifestError::InvalidManifest(msg) if msg == "image_type missing");
386    }
387
388    #[test]
389    fn test_serialize_ota_manifest() {
390        let manifest = OtaManifest {
391            build_info_version: SystemVersion::from_str("1.2.3.4").unwrap(),
392            board: "test-board".to_string(),
393            epoch: 1,
394            mode: UpdateMode::Normal,
395            blob_base_url: "http://example.com".to_string(),
396            images: vec![
397                Image {
398                    slot: Slot::AB,
399                    image_type: ImageType::Asset(AssetType::Zbi),
400                    sha256: [0; 32].into(),
401                    blob: Blob { uncompressed_size: 1234, fuchsia_merkle_root: [1; 32].into() },
402                },
403                Image {
404                    slot: Slot::AB,
405                    image_type: ImageType::Firmware("bootloader".to_string()),
406                    sha256: [2; 32].into(),
407                    blob: Blob { uncompressed_size: 3456, fuchsia_merkle_root: [3; 32].into() },
408                },
409            ],
410            blobs: vec![Blob { uncompressed_size: 5678, fuchsia_merkle_root: [4; 32].into() }],
411        };
412
413        let buf = manifest.clone().serialize();
414        let parsed_manifest = parse_ota_manifest(&buf).unwrap();
415
416        assert_eq!(manifest, parsed_manifest);
417    }
418
419    #[test]
420    fn image_from_path() {
421        let file = NamedTempFile::new().unwrap();
422        std::fs::write(file.path(), b"hello world").unwrap();
423
424        let image =
425            Image::from_path(file.path(), Slot::AB, ImageType::Asset(AssetType::Zbi)).unwrap();
426
427        assert_eq!(image.blob.uncompressed_size, 11);
428        assert_eq!(
429            image.sha256,
430            "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9".parse().unwrap()
431        );
432        assert_eq!(
433            image.blob.fuchsia_merkle_root,
434            "8af85e2fe5da3385ea468ed1cb8412eaea6530a90b5dd8dee96529c8d9d39b97".parse().unwrap()
435        );
436    }
437
438    #[test]
439    fn image_from_path_large_file() {
440        let mut file = NamedTempFile::new().unwrap();
441        let chunk = [1; 1024];
442        let mut sha256_hasher = sha2::Sha256::new();
443        let mut merkle_builder = fuchsia_merkle::BufferedMerkleRootBuilder::default();
444        for _ in 0..1000 {
445            file.write_all(&chunk).unwrap();
446            sha256_hasher.update(chunk);
447            merkle_builder.write(&chunk);
448        }
449
450        let image =
451            Image::from_path(file.path(), Slot::AB, ImageType::Asset(AssetType::Zbi)).unwrap();
452
453        assert_eq!(image.blob.uncompressed_size, 1000 * 1024);
454        assert_eq!(
455            image.sha256,
456            fuchsia_hash::Sha256::from(*AsRef::<[u8; 32]>::as_ref(&sha256_hasher.finalize()))
457        );
458        assert_eq!(image.blob.fuchsia_merkle_root, merkle_builder.complete());
459    }
460}