| // Copyright 2019 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. |
| |
| //! Test tools for building and serving TUF repositories containing Fuchsia packages. |
| |
| use { |
| crate::{ |
| package::Package, process::wait_for_process_termination, serve::ServedRepositoryBuilder, |
| }, |
| anyhow::{format_err, Context as _, Error}, |
| fdio::SpawnBuilder, |
| fidl_fuchsia_io::{DirectoryProxy, OPEN_FLAG_CREATE, OPEN_RIGHT_READABLE, OPEN_RIGHT_WRITABLE}, |
| fidl_fuchsia_pkg_ext::{ |
| MirrorConfig, RepositoryConfig, RepositoryConfigBuilder, RepositoryKey, |
| }, |
| files_async::readdir, |
| fuchsia_merkle::Hash, |
| fuchsia_url::pkg_url::RepoUrl, |
| io_util::{ |
| directory::{self, open_directory, open_file}, |
| file::{read, write}, |
| }, |
| maybe_owned::MaybeOwned, |
| serde::Deserialize, |
| std::{ |
| collections::{BTreeMap, BTreeSet, HashMap}, |
| fs::{self, File}, |
| io::{self, Read, Write}, |
| path::PathBuf, |
| sync::Arc, |
| }, |
| tempfile::TempDir, |
| walkdir::WalkDir, |
| }; |
| |
| /// A builder to simplify construction of TUF repositories containing Fuchsia packages. |
| #[derive(Debug, Default)] |
| pub struct RepositoryBuilder<'a> { |
| packages: Vec<MaybeOwned<'a, Package>>, |
| repodir: Option<PathBuf>, |
| } |
| |
| impl<'a> RepositoryBuilder<'a> { |
| /// Creates a new `RepositoryBuilder`. |
| pub fn new() -> Self { |
| Self::default() |
| } |
| |
| /// Creates a new `RepositoryBuilder` from a template TUF repository dir. |
| pub fn from_template_dir(path: impl Into<PathBuf>) -> Self { |
| Self { packages: vec![], repodir: Some(path.into()) } |
| } |
| |
| /// Adds a package (or a reference to one) to the repository. |
| pub fn add_package(mut self, package: impl Into<MaybeOwned<'a, Package>>) -> Self { |
| self.packages.push(package.into()); |
| self |
| } |
| |
| /// Builds the repository. |
| pub async fn build(self) -> Result<Repository, Error> { |
| let indir = tempfile::tempdir().context("create /in")?; |
| let repodir = tempfile::tempdir().context("create /repo")?; |
| |
| { |
| let mut manifest = File::create(indir.path().join("manifests.list"))?; |
| for package in &self.packages { |
| writeln!(manifest, "/packages/{}/manifest.json", package.name())?; |
| } |
| } |
| |
| // If configured to use a template repository directory, first copy it into the repo dir. |
| if let Some(templatedir) = self.repodir { |
| for entry in WalkDir::new(&templatedir) { |
| let entry = entry?; |
| if entry.path() == templatedir { |
| continue; |
| } |
| let relative_entry_path = entry.path().strip_prefix(&templatedir)?; |
| let target_path = repodir.path().join(relative_entry_path); |
| if entry.file_type().is_dir() { |
| fs::create_dir(target_path)?; |
| } else { |
| fs::copy(entry.path(), target_path)?; |
| } |
| } |
| } |
| |
| let mut pm = SpawnBuilder::new() |
| .options(fdio::SpawnOptions::CLONE_ALL - fdio::SpawnOptions::CLONE_NAMESPACE) |
| .arg("pm")? |
| .arg("publish")? |
| .arg("-lp")? |
| .arg("-f=/in/manifests.list")? |
| .arg("-repo=/repo")? |
| .add_dir_to_namespace("/in", File::open(indir.path()).context("open /in")?)? |
| .add_dir_to_namespace("/repo", File::open(repodir.path()).context("open /repo")?)?; |
| |
| for package in &self.packages { |
| let package = package.as_ref(); |
| pm = pm |
| .add_dir_to_namespace( |
| format!("/packages/{}", package.name()), |
| File::open(package.artifacts()).context("open package dir")?, |
| ) |
| .context("add package")?; |
| } |
| |
| let pm = pm |
| .spawn_from_path("/pkg/bin/pm", &fuchsia_runtime::job_default()) |
| .context("spawning pm to build repo")?; |
| |
| wait_for_process_termination(pm).await.context("waiting for pm to build repo")?; |
| |
| Ok(Repository { dir: repodir }) |
| } |
| } |
| |
| /// Metadata for a package contained within a [`Repository`]. |
| #[derive(Debug, PartialOrd, Ord, PartialEq, Eq)] |
| pub struct PackageEntry { |
| path: String, |
| meta_far_merkle: Hash, |
| meta_far_size: usize, |
| } |
| |
| pub(crate) fn iter_packages( |
| reader: impl Read, |
| ) -> Result<impl Iterator<Item = Result<PackageEntry, Error>>, Error> { |
| // TODO when metadata is compatible, use rust-tuf instead. |
| #[derive(Debug, Deserialize)] |
| struct TargetsJson { |
| signed: Targets, |
| } |
| #[derive(Debug, Deserialize)] |
| struct Targets { |
| targets: BTreeMap<String, Target>, |
| } |
| #[derive(Debug, Deserialize)] |
| struct Target { |
| custom: TargetCustom, |
| } |
| #[derive(Debug, Deserialize)] |
| struct TargetCustom { |
| merkle: String, |
| size: usize, |
| } |
| |
| let targets_json: TargetsJson = serde_json::from_reader(reader)?; |
| |
| Ok(targets_json.signed.targets.into_iter().map(|(path, target)| { |
| Ok(PackageEntry { |
| path, |
| meta_far_merkle: target.custom.merkle.parse()?, |
| meta_far_size: target.custom.size, |
| }) |
| })) |
| } |
| |
| /// A TUF repository generated by a [`RepositoryBuilder`]. |
| #[derive(Debug)] |
| pub struct Repository { |
| dir: TempDir, |
| } |
| |
| impl Repository { |
| /// Returns an iterator over all blobs contained in this repository. |
| pub fn iter_blobs(&self) -> Result<impl Iterator<Item = Result<Hash, Error>>, io::Error> { |
| Ok(fs::read_dir(self.dir.path().join("repository/blobs"))?.map(|entry| { |
| Ok(entry? |
| .file_name() |
| .to_str() |
| .ok_or_else(|| format_err!("non-utf8 file path"))? |
| .parse()?) |
| })) |
| } |
| |
| /// Returns a set of all blobs contained in this repository. |
| pub fn list_blobs(&self) -> Result<BTreeSet<Hash>, Error> { |
| self.iter_blobs()?.collect() |
| } |
| |
| /// Reads the contents of requested blob from the repository. |
| pub fn read_blob(&self, merkle_root: &Hash) -> Result<Vec<u8>, io::Error> { |
| fs::read(self.dir.path().join(format!("repository/blobs/{}", merkle_root))) |
| } |
| |
| /// Returns the path of the base of the repository. |
| pub fn path(&self) -> PathBuf { |
| self.dir.path().join("repository") |
| } |
| |
| /// Returns an iterator over all packages contained in this repository. |
| pub fn iter_packages( |
| &self, |
| ) -> Result<impl Iterator<Item = Result<PackageEntry, Error>>, Error> { |
| iter_packages(io::BufReader::new(File::open( |
| self.dir.path().join("repository/targets.json"), |
| )?)) |
| } |
| |
| /// Returns a sorted vector of all packages contained in this repository. |
| pub fn list_packages(&self) -> Result<Vec<PackageEntry>, Error> { |
| let mut packages = self.iter_packages()?.collect::<Result<Vec<_>, _>>()?; |
| packages.sort_unstable(); |
| Ok(packages) |
| } |
| |
| /// Generate a [`RepositoryConfigBuilder`] suitable for configuring a |
| /// package resolver to use this repository when it is served at the given |
| /// URL. |
| pub fn make_repo_config_builder(&self, url: RepoUrl) -> RepositoryConfigBuilder { |
| let mut builder = RepositoryConfigBuilder::new(url); |
| |
| for key in self.root_keys() { |
| builder = builder.add_root_key(key); |
| } |
| |
| builder |
| } |
| |
| /// Generate a [`RepositoryConfig`] suitable for configuring a package resolver to use this |
| /// repository when it is served at the given URL. |
| pub fn make_repo_config( |
| &self, |
| url: RepoUrl, |
| mirror_config: Option<MirrorConfig>, |
| use_local_mirror: bool, |
| ) -> RepositoryConfig { |
| let mut builder = self.make_repo_config_builder(url); |
| |
| if let Some(mirror_config) = mirror_config { |
| builder = builder.add_mirror(mirror_config) |
| } |
| |
| builder.use_local_mirror(use_local_mirror).build() |
| } |
| |
| /// Get the root keys used by this repository. |
| pub fn root_keys(&self) -> BTreeSet<RepositoryKey> { |
| // TODO when metadata is compatible, use rust-tuf instead. |
| #[derive(Debug, Deserialize)] |
| struct RootJson { |
| signed: Root, |
| } |
| #[derive(Debug, Deserialize)] |
| struct Root { |
| roles: BTreeMap<String, Role>, |
| keys: BTreeMap<String, Key>, |
| } |
| #[derive(Debug, Deserialize)] |
| struct Role { |
| keyids: Vec<String>, |
| } |
| #[derive(Debug, Deserialize)] |
| struct Key { |
| keyval: KeyVal, |
| } |
| #[derive(Debug, Deserialize)] |
| struct KeyVal { |
| public: String, |
| } |
| |
| let root_json: RootJson = serde_json::from_reader(io::BufReader::new( |
| File::open(self.dir.path().join("repository/root.json")).unwrap(), |
| )) |
| .unwrap(); |
| let root = root_json.signed; |
| |
| root.roles["root"] |
| .keyids |
| .iter() |
| .map(|keyid| { |
| RepositoryKey::Ed25519(hex::decode(root.keys[keyid].keyval.public.clone()).unwrap()) |
| }) |
| .collect() |
| } |
| |
| /// Serves the repository over HTTP using hyper. |
| pub fn server(self: Arc<Self>) -> ServedRepositoryBuilder { |
| ServedRepositoryBuilder::new(self) |
| } |
| |
| /// Copies the repository files to `dst` with a layout like that used by locally attached |
| /// repositories (which are used by e.g. the pkg-local-mirror component). `dst` must have |
| /// `OPEN_RIGHT_WRITABLE`. |
| /// |
| /// The layout looks like: |
| /// ./FORMAT_VERSION |
| /// ./blobs/ |
| /// 00/00000000000000000000000000000000000000000000000000000000000000 |
| /// ... |
| /// ff/ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff |
| /// ./repository_metadata/ |
| /// ${url.host()}/ |
| /// root.json |
| /// ... |
| pub async fn copy_local_repository_to_dir(&self, dst: &DirectoryProxy, url: &RepoUrl) { |
| let src = |
| directory::open_in_namespace(self.dir.path().to_str().unwrap(), OPEN_RIGHT_READABLE) |
| .unwrap(); |
| |
| let version = open_file(&dst, "FORMAT_VERSION", OPEN_FLAG_CREATE | OPEN_RIGHT_WRITABLE) |
| .await |
| .unwrap(); |
| write(&version, "loose").await.unwrap(); |
| |
| // Copy the blobs. Blobs are named after their hashes and (in the target layout) partitioned |
| // into subdirectories named after the first two hex characters of their hashes. |
| let blobs = |
| open_directory(&dst, "blobs", OPEN_FLAG_CREATE | OPEN_RIGHT_WRITABLE).await.unwrap(); |
| let src_blobs = |
| open_directory(&src, "repository/blobs", OPEN_RIGHT_READABLE).await.unwrap(); |
| let mut blob_sub_dirs = HashMap::new(); |
| for dirent in readdir(&src_blobs).await.unwrap() { |
| let sub_dir_name = &dirent.name[..2]; |
| let sub_dir = blob_sub_dirs.entry(sub_dir_name.to_owned()).or_insert_with(|| { |
| io_util::directory::open_directory_no_describe( |
| &blobs, |
| sub_dir_name, |
| OPEN_FLAG_CREATE | OPEN_RIGHT_WRITABLE, |
| ) |
| .unwrap() |
| }); |
| |
| let src_blob = open_file(&src_blobs, &dirent.name, OPEN_RIGHT_READABLE).await.unwrap(); |
| let contents = read(&src_blob).await.unwrap(); |
| let blob = |
| open_file(&sub_dir, &dirent.name[2..], OPEN_FLAG_CREATE | OPEN_RIGHT_WRITABLE) |
| .await |
| .unwrap(); |
| write(&blob, contents).await.unwrap(); |
| } |
| |
| // Copy the metadata. |
| let src_metadata = open_directory(&src, "repository", OPEN_RIGHT_READABLE).await.unwrap(); |
| let repository_metadata = |
| open_directory(&dst, "repository_metadata", OPEN_FLAG_CREATE | OPEN_RIGHT_WRITABLE) |
| .await |
| .unwrap(); |
| let hostname_dir = open_directory( |
| &repository_metadata, |
| url.host(), |
| OPEN_FLAG_CREATE | OPEN_RIGHT_WRITABLE, |
| ) |
| .await |
| .unwrap(); |
| |
| for dirent in readdir(&src_metadata).await.unwrap() { |
| if dirent.kind == files_async::DirentKind::File { |
| let src_metadata = |
| open_file(&src_metadata, &dirent.name, OPEN_RIGHT_READABLE).await.unwrap(); |
| let contents = read(&src_metadata).await.unwrap(); |
| let metadata = |
| open_file(&hostname_dir, &dirent.name, OPEN_FLAG_CREATE | OPEN_RIGHT_WRITABLE) |
| .await |
| .unwrap(); |
| write(&metadata, contents).await.unwrap(); |
| } |
| } |
| } |
| } |
| |
| #[cfg(test)] |
| mod tests { |
| use {super::*, crate::package::PackageBuilder, fuchsia_merkle::MerkleTree}; |
| |
| #[fuchsia_async::run_singlethreaded(test)] |
| async fn test_repo_builder() -> Result<(), Error> { |
| let same_contents = "same contents"; |
| let repo = RepositoryBuilder::new() |
| .add_package( |
| PackageBuilder::new("rolldice") |
| .add_resource_at("bin/rolldice", "#!/boot/bin/sh\necho 4\n".as_bytes()) |
| .add_resource_at( |
| "meta/rolldice.cmx", |
| r#"{"program":{"binary":"bin/rolldice"}}"#.as_bytes(), |
| ) |
| .add_resource_at("data/duplicate_a", "same contents".as_bytes()) |
| .build() |
| .await?, |
| ) |
| .add_package( |
| PackageBuilder::new("fortune") |
| .add_resource_at( |
| "bin/fortune", |
| "#!/boot/bin/sh\necho ask again later\n".as_bytes(), |
| ) |
| .add_resource_at( |
| "meta/fortune.cmx", |
| r#"{"program":{"binary":"bin/fortune"}}"#.as_bytes(), |
| ) |
| .add_resource_at("data/duplicate_b", same_contents.as_bytes()) |
| .add_resource_at("data/duplicate_c", same_contents.as_bytes()) |
| .build() |
| .await?, |
| ) |
| .build() |
| .await?; |
| |
| let blobs = repo.list_blobs()?; |
| // 2 meta FARs, 2 binaries, and 1 duplicated resource |
| assert_eq!(blobs.len(), 5); |
| |
| // Spot check the contents of a blob in the repo. |
| let same_contents_merkle = MerkleTree::from_reader(same_contents.as_bytes())?.root(); |
| assert_eq!(repo.read_blob(&same_contents_merkle)?.as_slice(), same_contents.as_bytes()); |
| |
| let packages = repo.list_packages()?; |
| assert_eq!(packages.len(), 2); |
| assert_eq!( |
| packages.into_iter().map(|pkg| pkg.path).collect::<Vec<_>>(), |
| vec!["fortune/0".to_owned(), "rolldice/0".to_owned()] |
| ); |
| |
| Ok(()) |
| } |
| |
| #[fuchsia_async::run_singlethreaded(test)] |
| async fn test_repo_builder_template() -> Result<(), Error> { |
| let repodir = tempfile::tempdir().context("create tempdir")?; |
| |
| // Populate repodir with a freshly created repository. |
| let pm = SpawnBuilder::new() |
| .options(fdio::SpawnOptions::CLONE_ALL - fdio::SpawnOptions::CLONE_NAMESPACE) |
| .arg("pm")? |
| .arg("newrepo")? |
| .arg("-repo=/repo")? |
| .add_dir_to_namespace( |
| "/repo".to_owned(), |
| File::open(repodir.path()).context("open /repo")?, |
| )? |
| .spawn_from_path("/pkg/bin/pm", &fuchsia_runtime::job_default()) |
| .context("spawning pm to build repo")?; |
| wait_for_process_termination(pm).await.context("waiting for pm to build repo")?; |
| |
| // Build a repo from the template. |
| let repo = RepositoryBuilder::from_template_dir(repodir.path()) |
| .add_package(PackageBuilder::new("test").build().await?) |
| .build() |
| .await?; |
| |
| // Ensure the repository used the generated keys. |
| for path in &["root.json", "snapshot.json", "timestamp.json", "targets.json"] { |
| assert_eq!( |
| fs::read(repodir.path().join("keys").join(path))?, |
| fs::read(repo.dir.path().join("keys").join(path))?, |
| ); |
| } |
| |
| Ok(()) |
| } |
| |
| #[fuchsia_async::run_singlethreaded(test)] |
| async fn local_mirror_dir() { |
| let repo = RepositoryBuilder::new() |
| .add_package(PackageBuilder::new("test").build().await.unwrap()) |
| .build() |
| .await |
| .unwrap(); |
| let local_repodir = tempfile::tempdir().unwrap(); |
| |
| repo.copy_local_repository_to_dir( |
| &directory::open_in_namespace( |
| local_repodir.path().to_str().unwrap(), |
| OPEN_RIGHT_WRITABLE, |
| ) |
| .unwrap(), |
| &"fuchsia-pkg://repo.example.org".parse().unwrap(), |
| ) |
| .await; |
| |
| let repo_config = |
| repo.make_repo_config("fuchsia-pkg://fuchsia.com".parse().unwrap(), None, true); |
| assert_eq!(repo_config.mirrors().len(), 0); |
| assert!(repo_config.use_local_mirror()); |
| assert_eq!( |
| repo_config.repo_url(), |
| &"fuchsia-pkg://fuchsia.com".parse::<RepoUrl>().unwrap() |
| ); |
| |
| assert_eq!(fs::read(local_repodir.path().join("FORMAT_VERSION")).unwrap(), b"loose"); |
| assert!(local_repodir |
| .path() |
| .join("repository_metadata/repo.example.org/1.root.json") |
| .exists()); |
| assert!(local_repodir |
| .path() |
| .join("blobs/e5/599ccbeeaae753738c54ec0dbe032cad2086727267ce68542451fbd20ef545") |
| .exists()); |
| } |
| } |