blob: a4e3566bf78c1d71bf09075cd0aa191860fa5d72 [file] [log] [blame]
// Copyright 2021 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
//! Representation of the product_bundle metadata.
mod v2;
use crate::VirtualDeviceManifest;
use anyhow::{anyhow, bail, Context, Result};
use camino::{Utf8Path, Utf8PathBuf};
use fuchsia_repo::repository::FileSystemRepository;
use serde::{Deserialize, Serialize};
use std::{fs::File, io::Read, ops::Deref};
use v2::Canonicalizer;
use zip::read::ZipArchive;
pub use v2::{ProductBundleV2, Repository, Type};
fn try_load_product_bundle(r: impl Read) -> Result<ProductBundle> {
let helper: SerializationHelper =
serde_json::from_reader(r).context("parsing product bundle")?;
match helper {
SerializationHelper::V1 { schema_id: _ } => {
bail!("Product Bundle v1 is no longer supported")
}
SerializationHelper::V2(SerializationHelperVersioned::V2(data)) => {
Ok(ProductBundle::V2(data))
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct ZipLoadedProductBundle {
product_bundle: ProductBundle,
}
impl ZipLoadedProductBundle {
pub fn try_load_from(product_bundle_zip_path: impl AsRef<Utf8Path>) -> Result<Self> {
let path = product_bundle_zip_path.as_ref();
let file =
File::open(path).with_context(|| format!("opening product bundle zip: {:?}", &path))?;
let zip =
ZipArchive::new(file).with_context(|| format!("loading zip file: {:?}", &path))?;
Self::load_from(zip)
}
pub fn load_from(mut zip: ZipArchive<File>) -> Result<Self> {
let product_bundle_manifest_name = zip
.file_names()
.find(|x| x == &"product_bundle.json" || x.ends_with("/product_bundle.json"))
.ok_or(anyhow!("finding file 'product_bundle.json' in zip archive"))?
.to_owned();
let product_bundle_parent_path =
product_bundle_manifest_name.strip_suffix("product_bundle.json").ok_or(anyhow!("despite the product_bundle.json being found, it's path did not include it as a suffix"))?;
let product_bundle_manifest = zip
.by_name(&product_bundle_manifest_name)
.with_context(|| format!("getting 'product_bundle.json' in zip archive"))?;
// Still need to canonicalize paths as the path to the product bundle'suffix
// parent directory may be arbitrarily deep in the zip file
match try_load_product_bundle(product_bundle_manifest)? {
ProductBundle::V2(data) => {
let mut data = data.clone();
let mut canonicalizer = ZipCanonicalizer::new(product_bundle_parent_path);
data.canonicalize_paths_with(product_bundle_parent_path, &mut canonicalizer)
.with_context(|| {
format!("Canonicalizing paths from {:?}", product_bundle_parent_path)
})?;
Ok(Self::new(ProductBundle::V2(data)))
}
}
}
pub fn new(product_bundle: ProductBundle) -> Self {
Self { product_bundle }
}
}
impl Deref for ZipLoadedProductBundle {
type Target = ProductBundle;
fn deref(&self) -> &Self::Target {
&self.product_bundle
}
}
impl Into<ProductBundle> for ZipLoadedProductBundle {
fn into(self) -> ProductBundle {
self.product_bundle
}
}
struct ZipCanonicalizer {
product_bundle_dir: Utf8PathBuf,
}
impl Canonicalizer for ZipCanonicalizer {
fn root_path(&self) -> &Utf8PathBuf {
&self.product_bundle_dir
}
fn canonicalize_path(
&self,
path: impl AsRef<Utf8Path>,
_image_types: Vec<Type>,
) -> Utf8PathBuf {
self.root_path().join(path).to_owned()
}
}
impl ZipCanonicalizer {
fn new(product_bundle_dir: impl Into<Utf8PathBuf>) -> Self {
Self { product_bundle_dir: product_bundle_dir.into() }
}
}
/// Returns a representation of a ProductBundle that has been loaded from disk.
///
/// The loaded product bundle holds a reference to the path that it was loaded
/// from so it can be referenced later. This helps when understanding how a
/// product bundle was loaded when it might have come from a default path.
///
/// Most users of the product bundle will not need to know, or care, where it
/// came from so they can just convert into a Product bundle using into().
#[derive(Clone, Debug, PartialEq)]
pub struct LoadedProductBundle {
product_bundle: ProductBundle,
from_path: Utf8PathBuf,
}
impl LoadedProductBundle {
/// Load a ProductBundle from a directory containing product_bundle.json
/// on disk. This method will return a LoadedProductBundle which keeps
/// track of where it was loaded from.
pub fn try_load_from(path: impl AsRef<Utf8Path>) -> Result<Self> {
if !path.as_ref().is_dir() {
anyhow::bail!("{} is not a directory", path.as_ref().as_str());
}
let product_bundle_path = path.as_ref().join("product_bundle.json");
let file = File::open(&product_bundle_path)
.map_err(|e| anyhow!("{e}: {product_bundle_path:?}"))?;
match try_load_product_bundle(file)? {
ProductBundle::V2(data) => {
let mut data = data.clone();
data.canonicalize_paths(path.as_ref())
.with_context(|| format!("Canonicalizing paths from {:?}", path.as_ref()))?;
Ok(LoadedProductBundle::new(ProductBundle::V2(data), path))
}
}
}
/// Creates a new LoadedProductBundle.
///
/// Users should prefer the try_load_from method over creating this struct
/// directly.
pub fn new(product_bundle: ProductBundle, from_path: impl AsRef<Utf8Path>) -> Self {
LoadedProductBundle { product_bundle, from_path: from_path.as_ref().into() }
}
/// Returns the path which the bundle was loaded from.
pub fn loaded_from_path(&self) -> &Utf8Path {
self.from_path.as_path()
}
}
impl Deref for LoadedProductBundle {
type Target = ProductBundle;
fn deref(&self) -> &Self::Target {
&self.product_bundle
}
}
impl Into<ProductBundle> for LoadedProductBundle {
fn into(self) -> ProductBundle {
self.product_bundle
}
}
/// Versioned product bundle.
#[derive(Clone, Debug, PartialEq)]
pub enum ProductBundle {
V2(ProductBundleV2),
}
/// Private helper for serializing the ProductBundle. A ProductBundle cannot be deserialized
/// without going through `try_from_path` in order to require that we use this helper, and the
/// `directory` field gets populated.
// TODO(https://fxbug.dev/324167674): fix.
#[allow(clippy::large_enum_variant)]
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(untagged)]
enum SerializationHelper {
V1 { schema_id: String },
V2(SerializationHelperVersioned),
}
/// Helper for serializing the new system of versioning product bundles using the "version" tag.
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(tag = "version")]
enum SerializationHelperVersioned {
#[serde(rename = "2")]
V2(ProductBundleV2),
}
impl ProductBundle {
pub fn try_load_from(path: impl AsRef<Utf8Path>) -> Result<Self> {
let path = path.as_ref();
if path.is_file() && path.extension() == Some("zip") {
ZipLoadedProductBundle::try_load_from(path).map(|v| v.into())
} else {
LoadedProductBundle::try_load_from(path).map(|v| v.into())
}
}
/// Write a product bundle to a directory on disk at `path`.
/// Note that this only writes the manifest file, and not the artifacts, images, blobs.
pub fn write(&self, path: impl AsRef<Utf8Path>) -> Result<()> {
let helper = match self {
Self::V2(data) => {
let mut data = data.clone();
data.relativize_paths(path.as_ref())?;
SerializationHelper::V2(SerializationHelperVersioned::V2(data))
}
};
let product_bundle_path = path.as_ref().join("product_bundle.json");
let file = File::create(product_bundle_path).context("creating product bundle file")?;
serde_json::to_writer_pretty(file, &helper).context("writing product bundle file")?;
Ok(())
}
/// Get the list of logical device names.
pub fn device_refs(&self) -> Result<Vec<String>> {
match self {
Self::V2(data) => {
let path = data.get_virtual_devices_path();
let manifest =
VirtualDeviceManifest::from_path(&path).context("manifest from_path")?;
Ok(manifest.device_names())
}
}
}
}
/// Construct a Vec<FileSystemRepository> from product bundle.
pub fn get_repositories(product_bundle_dir: Utf8PathBuf) -> Result<Vec<FileSystemRepository>> {
let pb = match ProductBundle::try_load_from(&product_bundle_dir)
.with_context(|| format!("loading {}", product_bundle_dir))?
{
ProductBundle::V2(pb) => pb,
};
let mut repos = Vec::<FileSystemRepository>::new();
for repo in pb.repositories {
let repo_builder = FileSystemRepository::builder(
repo.metadata_path
.canonicalize()
.with_context(|| format!("failed to canonicalize {:?}", repo.metadata_path))?
.try_into()?,
repo.blobs_path
.canonicalize()
.with_context(|| format!("failed to canonicalize {:?}", repo.blobs_path))?
.try_into()?,
)
.alias(repo.name)
.delivery_blob_type(repo.delivery_blob_type.try_into()?);
repos.push(repo_builder.build());
}
Ok(repos)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::io::Write;
use tempfile::TempDir;
use zip::{write::FileOptions, CompressionMethod, ZipWriter};
fn make_sample_pbv1(name: &str) -> serde_json::Value {
json!({
"schema_id": "http://fuchsia.com/schemas/sdk/product_bundle-6320eef1.json",
"data": {
"name": name,
"type": "product_bundle",
"device_refs": [name],
"images": [{
"base_uri": "file://fuchsia/development/0.20201216.2.1/images/generic-x64.tgz",
"format": "tgz"
}],
"manifests": {
},
"packages": [{
"format": "tgz",
"repo_uri": "file://fuchsia/development/0.20201216.2.1/packages/generic-x64.tar.gz"
}]
}
})
}
/// Macro to create a v1 product bundle in the tmp directory
macro_rules! make_pb_v1_in {
($dir:expr,$name:expr) => {{
let pb_dir = Utf8Path::from_path($dir.path()).unwrap();
let pb_file = File::create(pb_dir.join("product_bundle.json")).unwrap();
serde_json::to_writer(&pb_file, &make_sample_pbv1($name)).unwrap();
pb_dir
}};
}
fn make_sample_pbv2(name: &str) -> serde_json::Value {
json!({
"version": "2",
"product_name": name,
"product_version": "fake.pb-version",
"sdk_version": "fake.sdk-version",
"partitions": {
"hardware_revision": "board",
"bootstrap_partitions": [],
"bootloader_partitions": [],
"partitions": [],
"unlock_credentials": [],
},
})
}
/// Macro to create a v1 product bundle in the tmp directory
macro_rules! make_pb_v2_in {
($dir:expr,$name:expr) => {{
let pb_dir = Utf8Path::from_path($dir.path()).unwrap();
let pb_file = File::create(pb_dir.join("product_bundle.json")).unwrap();
serde_json::to_writer(&pb_file, &make_sample_pbv2($name)).unwrap();
pb_dir
}};
}
#[test]
fn test_parse_v1() {
let tmp = TempDir::new().unwrap();
let pb_dir = make_pb_v1_in!(tmp, "generic-x64");
assert!(LoadedProductBundle::try_load_from(pb_dir).is_err());
}
#[test]
fn test_parse_v2() {
let tmp = TempDir::new().unwrap();
let pb_dir = Utf8Path::from_path(tmp.path()).unwrap();
let pb_file = File::create(pb_dir.join("product_bundle.json")).unwrap();
serde_json::to_writer(&pb_file, &make_sample_pbv2(&"fake.pb-name")).unwrap();
let pb = LoadedProductBundle::try_load_from(pb_dir).unwrap();
assert!(matches!(pb.deref(), &ProductBundle::V2 { .. }));
}
#[test]
fn test_loaded_from_path() {
let tmp = TempDir::new().unwrap();
let pb_dir = make_pb_v2_in!(tmp, "generic-x64");
let pb = LoadedProductBundle::try_load_from(pb_dir).unwrap();
assert_eq!(pb_dir, pb.loaded_from_path());
}
#[test]
fn test_loaded_product_bundle_into() {
let tmp = TempDir::new().unwrap();
let pb_dir = make_pb_v2_in!(tmp, "generic-x64");
let pb: ProductBundle = LoadedProductBundle::try_load_from(pb_dir).unwrap().into();
assert!(matches!(pb, ProductBundle::V2 { .. }));
}
#[test]
fn test_loaded_from_product_bundle_deref() {
let tmp = TempDir::new().unwrap();
let pb_dir = make_pb_v2_in!(tmp, "generic-x64");
let pb = LoadedProductBundle::try_load_from(pb_dir).unwrap();
fn check_deref(_inner_pb: &ProductBundle) {
// Just make sure we have a compile time check.
assert!(true);
}
check_deref(&pb);
assert!(matches!(*pb.deref(), ProductBundle::V2 { .. }));
}
#[test]
fn test_zip_loaded() -> anyhow::Result<()> {
let tmp = TempDir::new().unwrap();
let pb = make_sample_pbv2("generic-x64");
let pb_filename = tmp.into_path().join("pb.zip");
let pb_file = File::create(pb_filename.clone())?;
let mut zip = ZipWriter::new(pb_file);
let options = FileOptions::default().compression_method(CompressionMethod::Stored);
zip.start_file("product_bundle.json", options)?;
let buf = serde_json::to_vec(&pb)?;
let _ = zip.write(&buf)?;
zip.flush()?;
let _ = zip.finish()?;
let _ = ZipLoadedProductBundle::try_load_from(Utf8Path::from_path(&pb_filename).unwrap())?;
Ok(())
}
#[test]
fn test_zip_product_bundle_into() -> anyhow::Result<()> {
let tmp = TempDir::new().unwrap();
let pb = make_sample_pbv2("generic-x64");
let pb_filename = tmp.into_path().join("pb.zip");
let pb_file = File::create(pb_filename.clone())?;
let mut zip = ZipWriter::new(pb_file);
let options = FileOptions::default().compression_method(CompressionMethod::Stored);
zip.start_file("product_bundle.json", options)?;
let buf = serde_json::to_vec(&pb)?;
let _ = zip.write(&buf)?;
zip.flush()?;
let _ = zip.finish()?;
let pb: ProductBundle =
ZipLoadedProductBundle::try_load_from(Utf8Path::from_path(&pb_filename).unwrap())?
.into();
assert!(matches!(pb, ProductBundle::V2 { .. }));
Ok(())
}
#[test]
fn test_zip_from_product_bundle_deref() -> anyhow::Result<()> {
let tmp = TempDir::new().unwrap();
let pb = make_sample_pbv2("generic-x64");
let pb_filename = tmp.into_path().join("pb.zip");
let pb_file = File::create(pb_filename.clone())?;
let mut zip = ZipWriter::new(pb_file);
let options = FileOptions::default().compression_method(CompressionMethod::Stored);
zip.start_file("product_bundle.json", options)?;
let buf = serde_json::to_vec(&pb)?;
let _ = zip.write(&buf)?;
zip.flush()?;
let _ = zip.finish()?;
let pb = ZipLoadedProductBundle::try_load_from(Utf8Path::from_path(&pb_filename).unwrap())?;
fn check_deref(_inner_pb: &ProductBundle) {
// Just make sure we have a compile time check.
assert!(true);
}
check_deref(&pb);
assert!(matches!(*pb.deref(), ProductBundle::V2 { .. }));
Ok(())
}
#[test]
fn test_product_bundle_try_load_from_for_zip() -> anyhow::Result<()> {
let tmp = TempDir::new().unwrap();
let pb = make_sample_pbv2("generic-x64");
let pb_filename = tmp.into_path().join("pb.zip");
let pb_file = File::create(pb_filename.clone())?;
let mut zip = ZipWriter::new(pb_file);
let options = FileOptions::default().compression_method(CompressionMethod::Stored);
zip.start_file("product_bundle.json", options)?;
let buf = serde_json::to_vec(&pb)?;
let _ = zip.write(&buf)?;
zip.flush()?;
let _ = zip.finish()?;
// This should detect zip file and load from the zip
let _ = ProductBundle::try_load_from(Utf8Path::from_path(&pb_filename).unwrap())?;
Ok(())
}
#[test]
fn test_no_file_fail_zip() -> anyhow::Result<()> {
let tmp = TempDir::new().unwrap();
let pb = make_sample_pbv2("generic-x64");
let pb_filename = tmp.into_path().join("pb.zip");
let pb_file = File::create(pb_filename.clone())?;
let mut zip = ZipWriter::new(pb_file);
let options = FileOptions::default().compression_method(CompressionMethod::Stored);
zip.start_file("for_sure_not_a_product_bundle.json", options)?;
let buf = serde_json::to_vec(&pb)?;
let _ = zip.write(&buf)?;
zip.flush()?;
let _ = zip.finish()?;
// This should detect zip file and load from the zip
assert!(ProductBundle::try_load_from(Utf8Path::from_path(&pb_filename).unwrap()).is_err());
Ok(())
}
#[test]
fn test_product_bundle_try_load_from_for_zip_deep_path() -> anyhow::Result<()> {
let tmp = TempDir::new().unwrap();
let pb = make_sample_pbv2("generic-x64");
let pb_filename = tmp.into_path().join("pb.zip");
let pb_file = File::create(pb_filename.clone())?;
let mut zip = ZipWriter::new(pb_file);
let options = FileOptions::default().compression_method(CompressionMethod::Stored);
// Now start the file deeper in the tree
zip.start_file("foo/bar/baz/biz/product_bundle.json", options)?;
let buf = serde_json::to_vec(&pb)?;
let _ = zip.write(&buf)?;
zip.flush()?;
let _ = zip.finish()?;
// This should detect zip file and load from the zip
let _ = ProductBundle::try_load_from(Utf8Path::from_path(&pb_filename).unwrap())?;
Ok(())
}
#[test]
fn test_parse_v1_from_zip_fails() -> anyhow::Result<()> {
let tmp = TempDir::new().unwrap();
let pb = make_sample_pbv1("generic-x64");
let pb_filename = tmp.into_path().join("pb.zip");
let pb_file = File::create(pb_filename.clone())?;
let mut zip = ZipWriter::new(pb_file);
let options = FileOptions::default().compression_method(CompressionMethod::Stored);
zip.start_file("for_sure_not_a_product_bundle.json", options)?;
let buf = serde_json::to_vec(&pb)?;
let _ = zip.write(&buf)?;
zip.flush()?;
let _ = zip.finish()?;
// This should fail as pbv1 is no longer supported.
assert!(ProductBundle::try_load_from(Utf8Path::from_path(&pb_filename).unwrap()).is_err());
Ok(())
}
#[test]
fn test_product_bundle_try_load_from_for_dir() -> anyhow::Result<()> {
let tmp = TempDir::new().unwrap();
let pb_dir = make_pb_v2_in!(tmp, "generic-x64");
let _ = ProductBundle::try_load_from(pb_dir).unwrap();
Ok(())
}
}