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    /// Metadata about the blob containing the image data.
86    pub blob: Blob,
87}
88
89impl Image {
90    /// Create a new `Image` from a file path.
91    pub fn from_path(
92        path: impl AsRef<std::path::Path>,
93        slot: Slot,
94        image_type: ImageType,
95    ) -> Result<Self, std::io::Error> {
96        let file = std::fs::File::open(path)?;
97        let size = file.metadata()?.len();
98        let fuchsia_merkle_root = fuchsia_merkle::root_from_reader(file)?;
99        Ok(Self { slot, image_type, blob: Blob { uncompressed_size: size, fuchsia_merkle_root } })
100    }
101}
102
103/// The type of the image.
104#[derive(Clone, Debug, PartialEq, Eq)]
105pub enum ImageType {
106    /// A standard system asset like ZBI or VBMETA.
107    Asset(AssetType),
108    /// A firmware image, with the field value specifying the firmware type.
109    Firmware(String),
110}
111
112/// Metadata for a blob.
113#[derive(Clone, Debug, PartialEq, Eq)]
114pub struct Blob {
115    /// The uncompressed size of the blob in bytes.
116    pub uncompressed_size: u64,
117    /// The fuchsia merkle root of the uncompressed blob data.
118    pub fuchsia_merkle_root: fuchsia_hash::Hash,
119}
120
121impl TryFrom<proto::OtaManifest> for OtaManifest {
122    type Error = String;
123    fn try_from(proto: proto::OtaManifest) -> Result<Self, Self::Error> {
124        let mode = proto.mode().into();
125        let version: Result<_, Infallible> =
126            crate::SystemVersion::from_str(&proto.build_info_version);
127        Ok(Self {
128            build_info_version: version.unwrap(),
129            board: proto.board,
130            epoch: proto.epoch,
131            mode,
132            blob_base_url: proto.blob_base_url,
133            images: proto
134                .images
135                .into_iter()
136                .map(TryInto::try_into)
137                .collect::<Result<Vec<_>, _>>()?,
138            blobs: proto.blobs.into_iter().map(TryInto::try_into).collect::<Result<Vec<_>, _>>()?,
139        })
140    }
141}
142
143impl From<OtaManifest> for proto::OtaManifest {
144    fn from(manifest: OtaManifest) -> Self {
145        Self {
146            build_info_version: manifest.build_info_version.to_string(),
147            board: manifest.board,
148            epoch: manifest.epoch,
149            mode: proto::UpdateMode::from(manifest.mode).into(),
150            blob_base_url: manifest.blob_base_url,
151            images: manifest.images.into_iter().map(Into::into).collect(),
152            blobs: manifest.blobs.into_iter().map(Into::into).collect(),
153        }
154    }
155}
156
157impl From<proto::UpdateMode> for UpdateMode {
158    fn from(mode: proto::UpdateMode) -> Self {
159        match mode {
160            proto::UpdateMode::Normal => UpdateMode::Normal,
161            proto::UpdateMode::ForceRecovery => UpdateMode::ForceRecovery,
162        }
163    }
164}
165
166impl From<UpdateMode> for proto::UpdateMode {
167    fn from(mode: UpdateMode) -> Self {
168        match mode {
169            UpdateMode::Normal => proto::UpdateMode::Normal,
170            UpdateMode::ForceRecovery => proto::UpdateMode::ForceRecovery,
171        }
172    }
173}
174
175impl TryFrom<proto::Image> for Image {
176    type Error = String;
177    fn try_from(image: proto::Image) -> Result<Self, Self::Error> {
178        let slot = image.slot().into();
179        let image_type =
180            image.image_type.ok_or_else(|| "image_type missing".to_string())?.try_into()?;
181        let blob = image.blob.ok_or_else(|| "blob missing".to_string())?.try_into()?;
182        Ok(Self { slot, image_type, blob })
183    }
184}
185
186impl From<Image> for proto::Image {
187    fn from(image: Image) -> Self {
188        Self {
189            slot: proto::Slot::from(image.slot).into(),
190            image_type: Some(image.image_type.into()),
191            blob: Some(image.blob.into()),
192        }
193    }
194}
195
196impl From<proto::Slot> for Slot {
197    fn from(slot: proto::Slot) -> Self {
198        match slot {
199            proto::Slot::Ab => Slot::AB,
200            proto::Slot::R => Slot::R,
201        }
202    }
203}
204
205impl From<Slot> for proto::Slot {
206    fn from(slot: Slot) -> Self {
207        match slot {
208            Slot::AB => proto::Slot::Ab,
209            Slot::R => proto::Slot::R,
210        }
211    }
212}
213
214impl TryFrom<proto::image::ImageType> for ImageType {
215    type Error = String;
216    fn try_from(value: proto::image::ImageType) -> Result<Self, Self::Error> {
217        match value {
218            proto::image::ImageType::Asset(asset) => {
219                let asset_type = proto::AssetType::from_i32(asset)
220                    .ok_or_else(|| format!("unknown asset type: {asset}"))?;
221                Ok(ImageType::Asset(asset_type))
222            }
223            proto::image::ImageType::Firmware(firmware) => Ok(ImageType::Firmware(firmware)),
224        }
225    }
226}
227
228impl From<ImageType> for proto::image::ImageType {
229    fn from(image_type: ImageType) -> Self {
230        match image_type {
231            ImageType::Asset(asset) => proto::image::ImageType::Asset(asset.into()),
232            ImageType::Firmware(firmware) => proto::image::ImageType::Firmware(firmware),
233        }
234    }
235}
236
237impl From<Blob> for proto::Blob {
238    fn from(blob: Blob) -> Self {
239        Self {
240            uncompressed_size: blob.uncompressed_size,
241            fuchsia_merkle_root: blob.fuchsia_merkle_root.as_ref().to_vec(),
242        }
243    }
244}
245
246impl TryFrom<proto::Blob> for Blob {
247    type Error = String;
248    fn try_from(blob: proto::Blob) -> Result<Self, Self::Error> {
249        Ok(Self {
250            uncompressed_size: blob.uncompressed_size,
251            fuchsia_merkle_root: fuchsia_hash::Hash::from(
252                <[u8; 32]>::try_from(blob.fuchsia_merkle_root)
253                    .map_err(|e| format!("invalid merkle root length: {e:?}"))?,
254            ),
255        })
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use std::assert_matches;
263    use std::io::Write as _;
264    use std::str::FromStr;
265    use tempfile::NamedTempFile;
266
267    #[test]
268    fn test_parse_ota_manifest_success() {
269        let proto_manifest = proto::OtaManifest {
270            build_info_version: "1.2.3.4".to_string(),
271            board: "test-board".to_string(),
272            epoch: 1,
273            mode: proto::UpdateMode::Normal.into(),
274            blob_base_url: "http://example.com".to_string(),
275            images: vec![
276                proto::Image {
277                    slot: proto::Slot::Ab.into(),
278                    image_type: Some(proto::image::ImageType::Asset(proto::AssetType::Zbi.into())),
279                    blob: Some(proto::Blob {
280                        uncompressed_size: 1234,
281                        fuchsia_merkle_root: vec![1; 32],
282                    }),
283                },
284                proto::Image {
285                    slot: proto::Slot::Ab.into(),
286                    image_type: Some(proto::image::ImageType::Firmware("bootloader".to_string())),
287                    blob: Some(proto::Blob {
288                        uncompressed_size: 3456,
289                        fuchsia_merkle_root: vec![3; 32],
290                    }),
291                },
292            ],
293            blobs: vec![proto::Blob { uncompressed_size: 5678, fuchsia_merkle_root: vec![4; 32] }],
294        };
295        let buf = proto_manifest.encode_to_vec();
296
297        let manifest = parse_ota_manifest(&buf).unwrap();
298        assert_eq!(manifest.build_info_version, SystemVersion::from_str("1.2.3.4").unwrap());
299        assert_eq!(manifest.board, "test-board");
300        assert_eq!(manifest.epoch, 1);
301        assert_eq!(manifest.mode, UpdateMode::Normal);
302        assert_eq!(manifest.blob_base_url, "http://example.com");
303
304        assert_eq!(manifest.images.len(), 2);
305        assert_eq!(manifest.images[0].slot, Slot::AB);
306        assert_eq!(manifest.images[0].image_type, ImageType::Asset(AssetType::Zbi));
307        assert_eq!(manifest.images[0].blob.uncompressed_size, 1234);
308        assert_eq!(manifest.images[0].blob.fuchsia_merkle_root, [1; 32].into());
309
310        assert_eq!(manifest.images[1].slot, Slot::AB);
311        assert_eq!(manifest.images[1].image_type, ImageType::Firmware("bootloader".to_string()));
312        assert_eq!(manifest.images[1].blob.uncompressed_size, 3456);
313        assert_eq!(manifest.images[1].blob.fuchsia_merkle_root, [3; 32].into());
314
315        assert_eq!(manifest.blobs.len(), 1);
316        assert_eq!(manifest.blobs[0].uncompressed_size, 5678);
317        assert_eq!(manifest.blobs[0].fuchsia_merkle_root, [4; 32].into());
318    }
319
320    #[test]
321    fn test_parse_ota_manifest_invalid_proto() {
322        let err = parse_ota_manifest(b"invalid proto").unwrap_err();
323        assert_matches!(err, OtaManifestError::ParseProto(_));
324    }
325
326    #[test]
327    fn test_parse_ota_manifest_invalid_manifest() {
328        let proto_manifest = proto::OtaManifest {
329            build_info_version: "1.2.3.4".to_string(),
330            board: "test-board".to_string(),
331            epoch: 1,
332            mode: proto::UpdateMode::Normal.into(),
333            blob_base_url: "http://example.com".to_string(),
334            images: vec![proto::Image {
335                slot: proto::Slot::Ab.into(),
336                image_type: None,
337                blob: Some(proto::Blob {
338                    uncompressed_size: 1234,
339                    fuchsia_merkle_root: vec![1; 32],
340                }),
341            }],
342            blobs: vec![],
343        };
344        let buf = proto_manifest.encode_to_vec();
345
346        let err = parse_ota_manifest(&buf).unwrap_err();
347        assert_matches!(err, OtaManifestError::InvalidManifest(msg) if msg == "image_type missing");
348    }
349
350    #[test]
351    fn test_serialize_ota_manifest() {
352        let manifest = OtaManifest {
353            build_info_version: SystemVersion::from_str("1.2.3.4").unwrap(),
354            board: "test-board".to_string(),
355            epoch: 1,
356            mode: UpdateMode::Normal,
357            blob_base_url: "http://example.com".to_string(),
358            images: vec![
359                Image {
360                    slot: Slot::AB,
361                    image_type: ImageType::Asset(AssetType::Zbi),
362                    blob: Blob { uncompressed_size: 1234, fuchsia_merkle_root: [1; 32].into() },
363                },
364                Image {
365                    slot: Slot::AB,
366                    image_type: ImageType::Firmware("bootloader".to_string()),
367                    blob: Blob { uncompressed_size: 3456, fuchsia_merkle_root: [3; 32].into() },
368                },
369            ],
370            blobs: vec![Blob { uncompressed_size: 5678, fuchsia_merkle_root: [4; 32].into() }],
371        };
372
373        let buf = manifest.clone().serialize();
374        let parsed_manifest = parse_ota_manifest(&buf).unwrap();
375
376        assert_eq!(manifest, parsed_manifest);
377    }
378
379    #[test]
380    fn image_from_path() {
381        let file = NamedTempFile::new().unwrap();
382        std::fs::write(file.path(), b"hello world").unwrap();
383
384        let image =
385            Image::from_path(file.path(), Slot::AB, ImageType::Asset(AssetType::Zbi)).unwrap();
386
387        assert_eq!(image.blob.uncompressed_size, 11);
388        assert_eq!(
389            image.blob.fuchsia_merkle_root,
390            "8af85e2fe5da3385ea468ed1cb8412eaea6530a90b5dd8dee96529c8d9d39b97".parse().unwrap()
391        );
392    }
393
394    #[test]
395    fn image_from_path_large_file() {
396        let mut file = NamedTempFile::new().unwrap();
397        let chunk = [1; 1024];
398        let mut merkle_builder = fuchsia_merkle::BufferedMerkleRootBuilder::default();
399        for _ in 0..1000 {
400            file.write_all(&chunk).unwrap();
401            merkle_builder.write(&chunk);
402        }
403
404        let image =
405            Image::from_path(file.path(), Slot::AB, ImageType::Asset(AssetType::Zbi)).unwrap();
406
407        assert_eq!(image.blob.uncompressed_size, 1000 * 1024);
408        assert_eq!(image.blob.fuchsia_merkle_root, merkle_builder.complete());
409    }
410}