diff --git a/crates/brioche-core/src/bake.rs b/crates/brioche-core/src/bake.rs index 4ec9e31..bf13517 100644 --- a/crates/brioche-core/src/bake.rs +++ b/crates/brioche-core/src/bake.rs @@ -20,6 +20,7 @@ use super::{ pub use process::{process_rootfs_recipes, ProcessRootfsRecipes}; +mod attach_resources; mod collect_references; mod download; mod process; @@ -248,7 +249,7 @@ async fn bake_inner( let baked = run_bake(&brioche, recipe.value, &meta).await?; // Send expensive recipes to optionally be synced to - // the registry right afer we baked it + // the registry right after we baked it if let Some(input_recipe) = input_recipe { brioche .sync_tx @@ -540,6 +541,16 @@ async fn run_bake(brioche: &Brioche, recipe: Recipe, meta: &Arc) -> anyhow Ok(Artifact::Directory(directory)) } + Recipe::AttachResources { recipe } => { + let artifact = bake(brioche, *recipe, &scope).await?; + let Artifact::Directory(mut directory) = artifact.value else { + anyhow::bail!("tried attaching resources for non-directory artifact"); + }; + + attach_resources::attach_resources(brioche, &mut directory).await?; + + Ok(Artifact::Directory(directory)) + } Recipe::Sync { recipe } => { let result = bake(brioche, *recipe, &scope).await?; Ok(result.value) diff --git a/crates/brioche-core/src/bake/attach_resources.rs b/crates/brioche-core/src/bake/attach_resources.rs new file mode 100644 index 0000000..80bb8a9 --- /dev/null +++ b/crates/brioche-core/src/bake/attach_resources.rs @@ -0,0 +1,449 @@ +use std::collections::{HashMap, HashSet, VecDeque}; + +use anyhow::Context as _; + +use crate::{ + recipe::{Artifact, ArtifactDiscriminants, Directory}, + Brioche, +}; + +/// Recursively walk a directory, attaching resources to directory entries +/// from discovered `brioche-resources.d` directories. +pub async fn attach_resources(brioche: &Brioche, directory: &mut Directory) -> anyhow::Result<()> { + // Build a graph to plan resources to attach + let plan = build_plan(brioche, directory).await?; + + // Sort nodes from the graph topologically. This gives us an order + // of paths to update, so that each path is processed after all of its + // dependencies are processed. + let planned_nodes = petgraph::algo::toposort(petgraph::visit::Reversed(&plan.graph), None) + .map_err(|error| { + let cycle_node = &plan.graph[error.node_id()]; + anyhow::anyhow!("resource cycle detected in path: {}", cycle_node.path) + })?; + + for node_index in planned_nodes { + let node = &plan.graph[node_index]; + + // Get the resources to attach based on graph edges. This only + // applies to file nodes. + let mut resources_to_attach = vec![]; + if node.kind == ArtifactDiscriminants::File { + let edges_out = plan + .graph + .edges_directed(node_index, petgraph::Direction::Outgoing); + + for edge in edges_out { + let AttachResourcesPlanEdge::InternalResource(resource) = &edge.weight(); + resources_to_attach.push(resource); + } + } + + // If there are no resources to attach, no need to update this node + if resources_to_attach.is_empty() { + continue; + }; + + // Get the artifact for this node. By this point, we know it should + // be a file artifact. + let artifact = directory + .get(brioche, &node.path) + .await? + .with_context(|| format!("failed to get artifact `{}`", node.path))?; + let Artifact::File(mut file) = artifact else { + anyhow::bail!("expected `{}` to be a file", node.path); + }; + + let mut artifact_changed = false; + + for resource in resources_to_attach { + // Get the resource artifact from the directory + let resource_resolved_path = resource.resolved_path(); + let resource_artifact = directory + .get(brioche, &resource_resolved_path) + .await? + .with_context(|| { + format!("failed to get resource `{}` for `{}` from resolved path `{resource_resolved_path}", resource.resource_path, node.path) + })?; + + // Insert the new resource in the file's resources + let replaced_resource = file + .resources + .insert( + brioche, + &resource.resource_path, + Some(resource_artifact.clone()), + ) + .await?; + + match replaced_resource { + None => { + // Added a new resource + artifact_changed = true; + } + Some(replaced_resource) => { + // Ensure that, if the resource already exists, it + // matches the newly-inserted resources. + // NOTE: This is currently more restrictive than + // how resources are handled by inputs, we may want + // to make this less strict. + anyhow::ensure!( + replaced_resource == resource_artifact, + "resource `{}` for `{}` did not match existing resource", + resource.resource_path, + node.path + ); + } + } + } + + // Insert the updated artifact back into the directory if it changed + if artifact_changed { + directory + .insert(brioche, &node.path, Some(Artifact::File(file))) + .await?; + } + } + + Ok(()) +} + +#[derive(Default)] +struct AttachResourcesPlan { + graph: petgraph::graph::DiGraph, + paths_to_nodes: HashMap, +} + +#[derive(Debug, Clone)] +struct AttachResourcesPlanNode { + path: bstr::BString, + kind: ArtifactDiscriminants, +} + +#[derive(Debug, Clone)] +enum AttachResourcesPlanEdge { + InternalResource(ResolvedResourcePath), +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct ResolvedResourcePath { + resource_dir_path: bstr::BString, + resource_path: bstr::BString, + resource_kind: ArtifactDiscriminants, +} + +impl ResolvedResourcePath { + fn resolved_path(&self) -> bstr::BString { + let mut resolved_path = self.resource_dir_path.clone(); + resolved_path.extend_from_slice(b"/"); + resolved_path.extend_from_slice(&self.resource_path); + resolved_path + } +} + +/// Plan resources to attach to artifacts within a directory by building +/// a graph. +async fn build_plan( + brioche: &Brioche, + directory: &Directory, +) -> anyhow::Result { + let mut plan = AttachResourcesPlan::default(); + let mut visited: HashSet = HashSet::new(); + + // Start with each entry of the directory + let entries = directory.entries(brioche).await?; + let mut queue: VecDeque<_> = entries.into_iter().collect(); + + while let Some((subpath, artifact)) = queue.pop_front() { + // Skip nodes we've already processed + if !visited.insert(subpath.clone()) { + continue; + } + + // Add a new node for this entry if one doesn't exist + let node = *plan + .paths_to_nodes + .entry(subpath.clone()) + .or_insert_with(|| { + plan.graph.add_node(AttachResourcesPlanNode { + path: subpath.clone(), + kind: ArtifactDiscriminants::from(&artifact), + }) + }); + + match artifact { + Artifact::File(file) => { + // Get the resources directly referenced by this file, which + // we resolved within the directory + let direct_resources = + resolve_internal_file_resources(brioche, directory, &subpath, &file).await?; + + // Get any indirect resources by these resources (e.g. symlinks) + let indirect_resources = + resolve_indirect_resources(brioche, directory, &subpath, &direct_resources) + .await?; + + let resources = direct_resources.into_iter().chain(indirect_resources); + + // Add an edge for each direct and indirect resource + for resource in resources { + let resolved_path = resource.resolved_path(); + let resource_node = *plan + .paths_to_nodes + .entry(resolved_path.clone()) + .or_insert_with(|| { + plan.graph.add_node(AttachResourcesPlanNode { + path: resolved_path, + kind: resource.resource_kind, + }) + }); + plan.graph.update_edge( + node, + resource_node, + AttachResourcesPlanEdge::InternalResource(resource), + ); + } + } + Artifact::Symlink { .. } => {} + Artifact::Directory(subdirectory) => { + let entries = subdirectory.entries(brioche).await?; + + // Enqueue each directory entry so we include them in the plan + for (name, entry) in entries { + let mut entry_subpath = subpath.clone(); + entry_subpath.extend_from_slice(b"/"); + entry_subpath.extend_from_slice(&name); + + queue.push_back((entry_subpath, entry)); + } + } + } + } + + Ok(plan) +} + +/// Resolve all the resources needed by a file within the given directory. +/// Each resource will be resolved by traversing up the directory, or +/// must already be present within the file's resources. +async fn resolve_internal_file_resources( + brioche: &Brioche, + directory: &Directory, + subpath: &[u8], + file: &crate::recipe::File, +) -> anyhow::Result> { + let subpath = bstr::BStr::new(subpath); + + // Get the file's blob + let blob_path = crate::blob::blob_path( + brioche, + &mut crate::blob::get_save_blob_permit().await?, + file.content_blob, + ) + .await?; + + // Try to extract a pack from the file's blob, and get its resource paths + // if it has any + let extracted = tokio::task::spawn_blocking(move || { + let file = std::fs::File::open(blob_path)?; + let extracted = brioche_pack::extract_pack(file).ok(); + + anyhow::Ok(extracted) + }) + .await??; + let pack = extracted.map(|extracted| extracted.pack); + let resource_paths = pack.into_iter().flat_map(|pack| pack.paths()); + + let mut resolved_resource_paths = vec![]; + + for resource_path in resource_paths { + // Find the resource internally by traversing up the directory + let resolved_resource = + resolve_internal_resource(brioche, directory, subpath, &resource_path).await?; + + // Get the existing resource attached to the file + let existing_resource = file.resources.get(brioche, &resource_path).await?; + + match (resolved_resource, existing_resource) { + (Some(resolved), _) => { + // We resolved the resource internally, so add it + // to the list + resolved_resource_paths.push(ResolvedResourcePath { + resource_dir_path: resolved.resource_dir_path.clone(), + resource_path, + resource_kind: ArtifactDiscriminants::from(&resolved.artifact), + }); + } + (None, Some(_)) => { + // Resource not found internally, but the resource already + // exists + } + (None, None) => { + // Resource not found internally and it wasn't already + // present on the file! + anyhow::bail!("resource `{resource_path}` required by `{subpath}` not found"); + } + } + } + + Ok(resolved_resource_paths) +} + +/// From a list of resources, get any additional resources that are +/// used indirectly (namely, as symlink targets from other resources). +async fn resolve_indirect_resources( + brioche: &Brioche, + directory: &Directory, + referrer_subpath: &[u8], + resources: &[ResolvedResourcePath], +) -> anyhow::Result> { + let referrer_subpath = bstr::BStr::new(referrer_subpath); + + // Start by visiting each of the provided resources + let mut resource_queue: VecDeque<_> = resources.iter().cloned().collect(); + let mut visited_resources = HashSet::::new(); + let mut indirect_resources = vec![]; + + while let Some(resource) = resource_queue.pop_front() { + if !visited_resources.insert(resource.clone()) { + continue; + } + + // Get the resource artifact + let resource_resolved_path = resource.resolved_path(); + let artifact = directory.get(brioche, &resource_resolved_path).await?; + let artifact = artifact.with_context(|| { + format!( + "failed to get resource `{}` from `{referrer_subpath}`", + resource.resource_path + ) + })?; + + match artifact { + Artifact::File(_) => { + // No indirect resources to get + } + Artifact::Symlink { target } => { + // Get the resource directory containing the current resource + let resource_dir = directory.get(brioche, &resource.resource_dir_path).await?; + let Some(Artifact::Directory(resource_dir)) = resource_dir else { + anyhow::bail!( + "failed to get resource directory for resource `{}` from `{referrer_subpath}`", + resource.resource_path, + ); + }; + + // Get the target path relative to the resource directory + let mut target_path = resource.resource_path.clone(); + target_path.extend_from_slice(b"/../"); + target_path.extend_from_slice(&target); + let target_path = crate::fs_utils::logical_path_bytes(&target_path).ok(); + + if let Some(target_path) = target_path { + // Try to resolve the target path from the resource + // directory. We check against the resource directory + // directly so we don't traverse outside + let target_path = bstr::BString::new(target_path); + let target_artifact = resource_dir.get(brioche, &target_path).await?; + + match target_artifact { + Some(Artifact::Symlink { .. }) => { + // TODO: Handle nested symlinks + anyhow::bail!( + "target of symlink {} is another symlink, which is not supported", + resource.resource_path, + ); + } + Some(target_artifact) => { + // Found a valid symlink target! This is an + // indirect resource + let indirect_resource = ResolvedResourcePath { + resource_dir_path: resource.resource_dir_path.clone(), + resource_path: target_path.clone(), + resource_kind: ArtifactDiscriminants::from(&target_artifact), + }; + + // Add it to the list of indirect resources, + // and queue it so we find more indirect resources + indirect_resources.push(indirect_resource.clone()); + resource_queue.push_back(indirect_resource); + } + None => { + // Broken symlink, ignore it + } + } + } + } + Artifact::Directory(directory) => { + // Queue each directory entry to look for more + // indirect resources + let entries = directory.entries(brioche).await?; + for (name, entry) in entries { + let mut entry_path = resource.resource_path.clone(); + entry_path.extend_from_slice(b"/"); + entry_path.extend_from_slice(&name); + + resource_queue.push_back(ResolvedResourcePath { + resource_dir_path: resource.resource_dir_path.clone(), + resource_path: entry_path, + resource_kind: ArtifactDiscriminants::from(&entry), + }); + } + } + } + } + + Ok(indirect_resources) +} + +struct ResolvedResource { + resource_dir_path: bstr::BString, + artifact: Artifact, +} + +/// Find the resource `resource_path` by traversing starting from `subpath`. +async fn resolve_internal_resource( + brioche: &Brioche, + directory: &Directory, + subpath: &[u8], + resource_path: &[u8], +) -> anyhow::Result> { + // Normalize the provided path, and start the search from there + let current_path = crate::fs_utils::logical_path_bytes(subpath)?; + let mut current_path = bstr::BString::new(current_path); + + loop { + // Get the parent directory path + let mut parent_path = current_path; + parent_path.extend_from_slice(b"/.."); + let parent_path = crate::fs_utils::logical_path_bytes(&parent_path); + let Ok(parent_path) = parent_path else { + return Ok(None); + }; + let parent_path = bstr::BString::new(parent_path); + current_path = parent_path; + + // Determine the resource directory path to search + let mut resource_dir_path = current_path.clone(); + resource_dir_path.extend_from_slice(b"/brioche-resources.d"); + let resource_dir_path = crate::fs_utils::logical_path_bytes(&resource_dir_path).ok(); + let Some(resource_dir_path) = resource_dir_path else { + continue; + }; + + // Try to get the resource directory path if it exists + let resource_dir = directory.get(brioche, &resource_dir_path).await?; + let Some(Artifact::Directory(resource_dir)) = resource_dir else { + continue; + }; + + let artifact = resource_dir.get(brioche, resource_path).await?; + if let Some(artifact) = artifact { + // This resource directory contains the resource, so we're done + return Ok(Some(ResolvedResource { + resource_dir_path: bstr::BString::from(resource_dir_path), + artifact, + })); + } + } +} diff --git a/crates/brioche-core/src/input.rs b/crates/brioche-core/src/input.rs index e4a09cb..f55bca2 100644 --- a/crates/brioche-core/src/input.rs +++ b/crates/brioche-core/src/input.rs @@ -524,10 +524,17 @@ fn add_input_plan_indirect_resources(plan: &mut CreateInputPlan) -> anyhow::Resu Some((node, resource_path.clone(), resource_node)) }) .collect(); + let mut visited_resource_paths = HashSet::new(); let mut indirect_resources = vec![]; - while let Some((node, resource_path, resource_node)) = resource_paths.pop() { + while let Some(visit) = resource_paths.pop() { + if !visited_resource_paths.insert(visit.clone()) { + continue; + } + + let (node, resource_path, resource_node) = visit; + for subresource_edge in plan.graph.edges(resource_node) { match subresource_edge.weight() { CreateInputPlanEdge::DirectoryEntry { file_name } => { diff --git a/crates/brioche-core/src/recipe.rs b/crates/brioche-core/src/recipe.rs index acc028d..6d3c155 100644 --- a/crates/brioche-core/src/recipe.rs +++ b/crates/brioche-core/src/recipe.rs @@ -96,10 +96,15 @@ pub enum Recipe { file: Box>, executable: Option, }, + #[serde(rename_all = "camelCase")] CollectReferences { recipe: Box>, }, #[serde(rename_all = "camelCase")] + AttachResources { + recipe: Box>, + }, + #[serde(rename_all = "camelCase")] Proxy(ProxyRecipe), #[serde(rename_all = "camelCase")] Sync { @@ -159,6 +164,7 @@ impl Recipe { | Recipe::Glob { .. } | Recipe::SetPermissions { .. } | Recipe::CollectReferences { .. } + | Recipe::AttachResources { .. } | Recipe::Proxy(_) => false, } } @@ -187,7 +193,7 @@ pub async fn get_recipes( // Release the lock drop(cached_recipes); - // Return early if we have no uncached recipess to fetch + // Return early if we have no uncached recipes to fetch if uncached_recipes.is_empty() { return Ok(recipes); } @@ -1044,6 +1050,7 @@ impl TryFrom for Artifact { | Recipe::Glob { .. } | Recipe::SetPermissions { .. } | Recipe::CollectReferences { .. } + | Recipe::AttachResources { .. } | Recipe::Proxy { .. } => Err(RecipeIncomplete), } } diff --git a/crates/brioche-core/src/references.rs b/crates/brioche-core/src/references.rs index ef1aa9c..02e9c91 100644 --- a/crates/brioche-core/src/references.rs +++ b/crates/brioche-core/src/references.rs @@ -133,6 +133,7 @@ pub fn referenced_blobs(recipe: &Recipe) -> Vec { | Recipe::SetPermissions { .. } | Recipe::Proxy(_) | Recipe::CollectReferences { .. } + | Recipe::AttachResources { .. } | Recipe::Sync { .. } => vec![], } } @@ -264,6 +265,7 @@ pub fn referenced_recipes(recipe: &Recipe) -> Vec { } => referenced_recipes(file), Recipe::Proxy(proxy) => vec![proxy.recipe], Recipe::CollectReferences { recipe } => referenced_recipes(recipe), + Recipe::AttachResources { recipe } => referenced_recipes(recipe), Recipe::Sync { recipe } => referenced_recipes(recipe), } } diff --git a/crates/brioche-core/tests/bake_attach_resources.rs b/crates/brioche-core/tests/bake_attach_resources.rs new file mode 100644 index 0000000..1664f1f --- /dev/null +++ b/crates/brioche-core/tests/bake_attach_resources.rs @@ -0,0 +1,781 @@ +use assert_matches::assert_matches; +use pretty_assertions::assert_eq; + +use brioche_core::{blob::BlobHash, recipe::Recipe}; +use brioche_test_support::{bake_without_meta, brioche_test, without_meta}; + +async fn blob_with_resource_paths( + brioche: &brioche_core::Brioche, + content: impl AsRef<[u8]>, + resource_paths: impl IntoIterator>, +) -> BlobHash { + let mut content = content.as_ref().to_vec(); + let resource_paths = resource_paths + .into_iter() + .map(|path| path.as_ref().to_vec()) + .collect(); + brioche_pack::inject_pack( + &mut content, + &brioche_pack::Pack::Metadata { + resource_paths, + format: "test".into(), + metadata: vec![], + }, + ) + .expect("failed to inject pack"); + + brioche_test_support::blob(brioche, content).await +} + +#[tokio::test] +async fn test_bake_attach_resources_without_resources() -> anyhow::Result<()> { + let (brioche, _context) = brioche_test().await; + + let foo_blob = brioche_test_support::blob(&brioche, b"foo").await; + let bar_blob = brioche_test_support::blob(&brioche, b"bar").await; + let baz_blob = brioche_test_support::blob(&brioche, b"baz").await; + + let dir = brioche_test_support::dir( + &brioche, + [ + ("foo.txt", brioche_test_support::file(foo_blob, false)), + ("bar.txt", brioche_test_support::file(bar_blob, true)), + ( + "brioche-resources.d", + brioche_test_support::symlink("../brioche-resources.d"), + ), + ( + "dir", + brioche_test_support::dir( + &brioche, + [("baz.txt", brioche_test_support::file(baz_blob, false))], + ) + .await, + ), + ], + ) + .await; + let recipe = Recipe::AttachResources { + recipe: Box::new(without_meta(dir.clone().into())), + }; + + let output = bake_without_meta(&brioche, recipe).await?; + assert_eq!(output, dir); + + Ok(()) +} + +#[tokio::test] +async fn test_bake_attach_resources_add_all_resources() -> anyhow::Result<()> { + let (brioche, _context) = brioche_test().await; + + let foo_blob = blob_with_resource_paths(&brioche, b"foo", ["fizz/a"]).await; + let bar_blob = blob_with_resource_paths(&brioche, b"bar", ["fizz/b", "fizz/c", "d.txt"]).await; + let baz_blob = blob_with_resource_paths(&brioche, b"baz", ["fizz/c", "buzz", "e.lnk"]).await; + + let a_blob = brioche_test_support::blob(&brioche, b"a").await; + let b_blob = brioche_test_support::blob(&brioche, b"b").await; + let c_blob = brioche_test_support::blob(&brioche, b"c").await; + let d_blob = brioche_test_support::blob(&brioche, b"d").await; + let e_blob = brioche_test_support::blob(&brioche, b"e").await; + + let dir = brioche_test_support::dir( + &brioche, + [ + ("foo.txt", brioche_test_support::file(foo_blob, false)), + ("bar.txt", brioche_test_support::file(bar_blob, true)), + ("dir/baz.txt", brioche_test_support::file(baz_blob, false)), + ( + "brioche-resources.d", + brioche_test_support::dir( + &brioche, + [ + ("fizz/a", brioche_test_support::file(a_blob, false)), + ("fizz/b", brioche_test_support::file(b_blob, true)), + ("fizz/c", brioche_test_support::file(c_blob, false)), + ("buzz/a.txt", brioche_test_support::symlink("../fizz/a")), + ( + "buzz/b.txt", + brioche_test_support::symlink("../fizz/broken.txt"), + ), + ("buzz/d.txt", brioche_test_support::symlink("../d.txt")), + ("d.txt", brioche_test_support::file(d_blob, false)), + ("d.lnk", brioche_test_support::symlink("d.txt")), + ("e.txt", brioche_test_support::file(e_blob, false)), + ("e.lnk", brioche_test_support::symlink("e.txt")), + ], + ) + .await, + ), + ], + ) + .await; + let recipe = Recipe::AttachResources { + recipe: Box::new(without_meta(dir.clone().into())), + }; + + let expected_output = brioche_test_support::dir( + &brioche, + [ + ( + "foo.txt", + brioche_test_support::file_with_resources( + foo_blob, + false, + brioche_test_support::dir_value( + &brioche, + [("fizz/a", brioche_test_support::file(a_blob, false))], + ) + .await, + ), + ), + ( + "bar.txt", + brioche_test_support::file_with_resources( + bar_blob, + true, + brioche_test_support::dir_value( + &brioche, + [ + ("fizz/b", brioche_test_support::file(b_blob, true)), + ("fizz/c", brioche_test_support::file(c_blob, false)), + ("d.txt", brioche_test_support::file(d_blob, false)), + ], + ) + .await, + ), + ), + ( + "dir", + brioche_test_support::dir( + &brioche, + [( + "baz.txt", + brioche_test_support::file_with_resources( + baz_blob, + false, + brioche_test_support::dir_value( + &brioche, + [ + ("fizz/a", brioche_test_support::file(a_blob, false)), + ("fizz/c", brioche_test_support::file(c_blob, false)), + ("buzz/a.txt", brioche_test_support::symlink("../fizz/a")), + ( + "buzz/b.txt", + brioche_test_support::symlink("../fizz/broken.txt"), + ), + ("buzz/d.txt", brioche_test_support::symlink("../d.txt")), + ("d.txt", brioche_test_support::file(d_blob, false)), + ("e.txt", brioche_test_support::file(e_blob, false)), + ("e.lnk", brioche_test_support::symlink("e.txt")), + ], + ) + .await, + ), + )], + ) + .await, + ), + ( + "brioche-resources.d", + brioche_test_support::dir( + &brioche, + [ + ("fizz/a", brioche_test_support::file(a_blob, false)), + ("fizz/b", brioche_test_support::file(b_blob, true)), + ("fizz/c", brioche_test_support::file(c_blob, false)), + ("buzz/a.txt", brioche_test_support::symlink("../fizz/a")), + ( + "buzz/b.txt", + brioche_test_support::symlink("../fizz/broken.txt"), + ), + ("buzz/d.txt", brioche_test_support::symlink("../d.txt")), + ("d.txt", brioche_test_support::file(d_blob, false)), + ("d.lnk", brioche_test_support::symlink("d.txt")), + ("e.txt", brioche_test_support::file(e_blob, false)), + ("e.lnk", brioche_test_support::symlink("e.txt")), + ], + ) + .await, + ), + ], + ) + .await; + + let output = bake_without_meta(&brioche, recipe).await?; + + assert_eq!(output, expected_output); + + Ok(()) +} + +#[tokio::test] +async fn test_bake_attach_resources_keep_existing_resources() -> anyhow::Result<()> { + let (brioche, _context) = brioche_test().await; + + let foo_blob = brioche_test_support::blob(&brioche, b"foo").await; + let bar_blob = brioche_test_support::blob(&brioche, b"bar").await; + let baz_blob = brioche_test_support::blob(&brioche, b"baz").await; + + let a_blob = brioche_test_support::blob(&brioche, b"a").await; + let b_blob = brioche_test_support::blob(&brioche, b"b").await; + let c_blob = brioche_test_support::blob(&brioche, b"c").await; + let d_blob = brioche_test_support::blob(&brioche, b"d").await; + let e_blob = brioche_test_support::blob(&brioche, b"e").await; + + let dir = brioche_test_support::dir( + &brioche, + [ + ( + "foo.txt", + brioche_test_support::file_with_resources( + foo_blob, + false, + brioche_test_support::dir_value( + &brioche, + [("fizz/a", brioche_test_support::file(a_blob, false))], + ) + .await, + ), + ), + ( + "bar.txt", + brioche_test_support::file_with_resources( + bar_blob, + true, + brioche_test_support::dir_value( + &brioche, + [ + ("fizz/b", brioche_test_support::file(b_blob, true)), + ("fizz/c", brioche_test_support::file(c_blob, false)), + ("d.txt", brioche_test_support::file(d_blob, false)), + ], + ) + .await, + ), + ), + ( + "dir", + brioche_test_support::dir( + &brioche, + [( + "baz.txt", + brioche_test_support::file_with_resources( + baz_blob, + false, + brioche_test_support::dir_value( + &brioche, + [ + ("fizz/a", brioche_test_support::file(a_blob, false)), + ("fizz/c", brioche_test_support::file(c_blob, false)), + ("buzz/a.txt", brioche_test_support::symlink("../fizz/a")), + ( + "buzz/b.txt", + brioche_test_support::symlink("../fizz/broken.txt"), + ), + ("buzz/d.txt", brioche_test_support::symlink("../d.txt")), + ("d.txt", brioche_test_support::file(d_blob, false)), + ("e.txt", brioche_test_support::file(e_blob, false)), + ("e.lnk", brioche_test_support::symlink("e.txt")), + ], + ) + .await, + ), + )], + ) + .await, + ), + ( + "brioche-resources.d", + brioche_test_support::dir( + &brioche, + [ + ("fizz/a", brioche_test_support::file(a_blob, false)), + ("fizz/b", brioche_test_support::file(b_blob, true)), + ("fizz/c", brioche_test_support::file(c_blob, false)), + ("buzz/a.txt", brioche_test_support::symlink("../fizz/a")), + ( + "buzz/b.txt", + brioche_test_support::symlink("../fizz/broken.txt"), + ), + ("buzz/d.txt", brioche_test_support::symlink("../d.txt")), + ("d.txt", brioche_test_support::file(d_blob, false)), + ("d.lnk", brioche_test_support::symlink("d.txt")), + ("e.txt", brioche_test_support::file(e_blob, false)), + ("e.lnk", brioche_test_support::symlink("e.txt")), + ], + ) + .await, + ), + ], + ) + .await; + + let recipe = Recipe::AttachResources { + recipe: Box::new(without_meta(dir.clone().into())), + }; + + let output = bake_without_meta(&brioche, recipe).await?; + + assert_eq!(output, dir); + + Ok(()) +} + +#[tokio::test] +async fn test_bake_attach_resources_layers() -> anyhow::Result<()> { + let (brioche, _context) = brioche_test().await; + + let foo_blob = blob_with_resource_paths(&brioche, b"foo", ["a"]).await; + let bar_blob = blob_with_resource_paths(&brioche, b"bar", ["b", "c"]).await; + let baz_blob = blob_with_resource_paths(&brioche, b"baz", ["a", "c"]).await; + + let a1_blob = brioche_test_support::blob(&brioche, b"a1").await; + let b1_blob = brioche_test_support::blob(&brioche, b"b1").await; + let c1_blob = brioche_test_support::blob(&brioche, b"c1").await; + + let a2_blob = brioche_test_support::blob(&brioche, b"a2").await; + let b2_blob = brioche_test_support::blob(&brioche, b"b2").await; + let c2_blob = brioche_test_support::blob(&brioche, b"c2").await; + + let dir = brioche_test_support::dir( + &brioche, + [ + ("foo.txt", brioche_test_support::file(foo_blob, false)), + ("bar.txt", brioche_test_support::file(bar_blob, false)), + ("baz.txt", brioche_test_support::file(baz_blob, false)), + ("fizz/foo.txt", brioche_test_support::file(foo_blob, false)), + ("fizz/bar.txt", brioche_test_support::file(bar_blob, false)), + ("fizz/baz.txt", brioche_test_support::file(baz_blob, false)), + ( + "fizz/buzz/foo.txt", + brioche_test_support::file(foo_blob, false), + ), + ( + "fizz/buzz/bar.txt", + brioche_test_support::file(bar_blob, false), + ), + ( + "fizz/buzz/baz.txt", + brioche_test_support::file(baz_blob, false), + ), + ( + "brioche-resources.d/a", + brioche_test_support::file(a1_blob, false), + ), + ( + "brioche-resources.d/b", + brioche_test_support::file(b1_blob, false), + ), + ( + "brioche-resources.d/c", + brioche_test_support::file(c1_blob, false), + ), + ( + "fizz/brioche-resources.d/b", + brioche_test_support::file(b2_blob, false), + ), + ( + "fizz/buzz/brioche-resources.d/a", + brioche_test_support::file(a2_blob, false), + ), + ( + "fizz/buzz/brioche-resources.d/c", + brioche_test_support::file(c2_blob, false), + ), + ], + ) + .await; + let recipe = Recipe::AttachResources { + recipe: Box::new(without_meta(dir.clone().into())), + }; + + let expected_output = brioche_test_support::dir( + &brioche, + [ + ( + "foo.txt", + brioche_test_support::file_with_resources( + foo_blob, + false, + brioche_test_support::dir_value( + &brioche, + [("a", brioche_test_support::file(a1_blob, false))], + ) + .await, + ), + ), + ( + "bar.txt", + brioche_test_support::file_with_resources( + bar_blob, + false, + brioche_test_support::dir_value( + &brioche, + [ + ("b", brioche_test_support::file(b1_blob, false)), + ("c", brioche_test_support::file(c1_blob, false)), + ], + ) + .await, + ), + ), + ( + "baz.txt", + brioche_test_support::file_with_resources( + baz_blob, + false, + brioche_test_support::dir_value( + &brioche, + [ + ("a", brioche_test_support::file(a1_blob, false)), + ("c", brioche_test_support::file(c1_blob, false)), + ], + ) + .await, + ), + ), + ( + "fizz/foo.txt", + brioche_test_support::file_with_resources( + foo_blob, + false, + brioche_test_support::dir_value( + &brioche, + [("a", brioche_test_support::file(a1_blob, false))], + ) + .await, + ), + ), + ( + "fizz/bar.txt", + brioche_test_support::file_with_resources( + bar_blob, + false, + brioche_test_support::dir_value( + &brioche, + [ + ("b", brioche_test_support::file(b2_blob, false)), + ("c", brioche_test_support::file(c1_blob, false)), + ], + ) + .await, + ), + ), + ( + "fizz/baz.txt", + brioche_test_support::file_with_resources( + baz_blob, + false, + brioche_test_support::dir_value( + &brioche, + [ + ("a", brioche_test_support::file(a1_blob, false)), + ("c", brioche_test_support::file(c1_blob, false)), + ], + ) + .await, + ), + ), + ( + "fizz/buzz/foo.txt", + brioche_test_support::file_with_resources( + foo_blob, + false, + brioche_test_support::dir_value( + &brioche, + [("a", brioche_test_support::file(a2_blob, false))], + ) + .await, + ), + ), + ( + "fizz/buzz/bar.txt", + brioche_test_support::file_with_resources( + bar_blob, + false, + brioche_test_support::dir_value( + &brioche, + [ + ("b", brioche_test_support::file(b2_blob, false)), + ("c", brioche_test_support::file(c2_blob, false)), + ], + ) + .await, + ), + ), + ( + "fizz/buzz/baz.txt", + brioche_test_support::file_with_resources( + baz_blob, + false, + brioche_test_support::dir_value( + &brioche, + [ + ("a", brioche_test_support::file(a2_blob, false)), + ("c", brioche_test_support::file(c2_blob, false)), + ], + ) + .await, + ), + ), + ( + "brioche-resources.d/a", + brioche_test_support::file(a1_blob, false), + ), + ( + "brioche-resources.d/b", + brioche_test_support::file(b1_blob, false), + ), + ( + "brioche-resources.d/c", + brioche_test_support::file(c1_blob, false), + ), + ( + "fizz/brioche-resources.d/b", + brioche_test_support::file(b2_blob, false), + ), + ( + "fizz/buzz/brioche-resources.d/a", + brioche_test_support::file(a2_blob, false), + ), + ( + "fizz/buzz/brioche-resources.d/c", + brioche_test_support::file(c2_blob, false), + ), + ], + ) + .await; + + let output = bake_without_meta(&brioche, recipe).await?; + + assert_eq!(output, expected_output); + + Ok(()) +} + +#[tokio::test] +async fn test_bake_attach_resources_with_external_resources() -> anyhow::Result<()> { + let (brioche, _context) = brioche_test().await; + + let top_blob = blob_with_resource_paths(&brioche, b"top", ["fizz"]).await; + let fizz_blob = blob_with_resource_paths(&brioche, b"fizz", ["buzz"]).await; + let buzz_blob = brioche_test_support::blob(&brioche, b"buzz!").await; + + let dir = brioche_test_support::dir( + &brioche, + [ + ( + "inside/top.txt", + brioche_test_support::file(top_blob, false), + ), + ( + "inside/brioche-resources.d/fizz", + brioche_test_support::file(fizz_blob, false), + ), + ( + "brioche-resources.d/buzz", + brioche_test_support::file(buzz_blob, false), + ), + ], + ) + .await; + let recipe = Recipe::AttachResources { + recipe: Box::new(without_meta(dir.clone().into())), + }; + + let expected_output = brioche_test_support::dir( + &brioche, + [ + ( + "inside/top.txt", + brioche_test_support::file_with_resources( + top_blob, + false, + brioche_test_support::dir_value( + &brioche, + [( + "fizz", + brioche_test_support::file_with_resources( + fizz_blob, + false, + brioche_test_support::dir_value( + &brioche, + [("buzz", brioche_test_support::file(buzz_blob, false))], + ) + .await, + ), + )], + ) + .await, + ), + ), + ( + "inside/brioche-resources.d/fizz", + brioche_test_support::file_with_resources( + fizz_blob, + false, + brioche_test_support::dir_value( + &brioche, + [("buzz", brioche_test_support::file(buzz_blob, false))], + ) + .await, + ), + ), + ( + "brioche-resources.d/buzz", + brioche_test_support::file(buzz_blob, false), + ), + ], + ) + .await; + + let output = bake_without_meta(&brioche, recipe).await?; + + assert_eq!(output, expected_output); + + Ok(()) +} + +#[tokio::test] +async fn test_bake_attach_resources_with_internal_resources() -> anyhow::Result<()> { + let (brioche, _context) = brioche_test().await; + + let top_blob = blob_with_resource_paths(&brioche, b"top", ["fizz"]).await; + let fizz_blob = blob_with_resource_paths(&brioche, b"fizz", ["buzz"]).await; + let buzz_blob = brioche_test_support::blob(&brioche, b"buzz!").await; + + let dir = brioche_test_support::dir( + &brioche, + [ + ("top.txt", brioche_test_support::file(top_blob, false)), + ( + "brioche-resources.d/fizz", + brioche_test_support::file(fizz_blob, false), + ), + ( + "brioche-resources.d/buzz", + brioche_test_support::file(buzz_blob, false), + ), + ], + ) + .await; + let recipe = Recipe::AttachResources { + recipe: Box::new(without_meta(dir.clone().into())), + }; + + let expected_output = brioche_test_support::dir( + &brioche, + [ + ( + "top.txt", + brioche_test_support::file_with_resources( + top_blob, + false, + brioche_test_support::dir_value( + &brioche, + [( + "fizz", + brioche_test_support::file_with_resources( + fizz_blob, + false, + brioche_test_support::dir_value( + &brioche, + [("buzz", brioche_test_support::file(buzz_blob, false))], + ) + .await, + ), + )], + ) + .await, + ), + ), + ( + "brioche-resources.d/fizz", + brioche_test_support::file_with_resources( + fizz_blob, + false, + brioche_test_support::dir_value( + &brioche, + [("buzz", brioche_test_support::file(buzz_blob, false))], + ) + .await, + ), + ), + ( + "brioche-resources.d/buzz", + brioche_test_support::file(buzz_blob, false), + ), + ], + ) + .await; + + let output = bake_without_meta(&brioche, recipe).await?; + + assert_eq!(output, expected_output); + + Ok(()) +} + +#[tokio::test] +async fn test_bake_attach_resources_with_recursive_resource_error() -> anyhow::Result<()> { + let (brioche, _context) = brioche_test().await; + + let top_blob = blob_with_resource_paths(&brioche, b"top", ["fizz"]).await; + let fizz_blob = blob_with_resource_paths(&brioche, b"fizz", ["fizz"]).await; + + let dir = brioche_test_support::dir( + &brioche, + [ + ("top.txt", brioche_test_support::file(top_blob, false)), + ( + "brioche-resources.d/fizz", + brioche_test_support::file(fizz_blob, false), + ), + ], + ) + .await; + let recipe = Recipe::AttachResources { + recipe: Box::new(without_meta(dir.clone().into())), + }; + + let result = bake_without_meta(&brioche, recipe).await; + + assert_matches!(result, Err(_)); + + Ok(()) +} + +#[tokio::test] +async fn test_bake_attach_resources_with_mutually_recursive_resource_error() -> anyhow::Result<()> { + let (brioche, _context) = brioche_test().await; + + let top_blob = blob_with_resource_paths(&brioche, b"top", ["fizz"]).await; + let fizz_blob = blob_with_resource_paths(&brioche, b"fizz", ["buzz"]).await; + let buzz_blob = blob_with_resource_paths(&brioche, b"buzz", ["fizz"]).await; + + let dir = brioche_test_support::dir( + &brioche, + [ + ("top.txt", brioche_test_support::file(top_blob, false)), + ( + "brioche-resources.d/fizz", + brioche_test_support::file(fizz_blob, false), + ), + ( + "brioche-resources.d/buzz", + brioche_test_support::file(buzz_blob, false), + ), + ], + ) + .await; + let recipe = Recipe::AttachResources { + recipe: Box::new(without_meta(dir.clone().into())), + }; + + let result = bake_without_meta(&brioche, recipe).await; + + assert_matches!(result, Err(_)); + + Ok(()) +} diff --git a/crates/brioche-core/tests/input.rs b/crates/brioche-core/tests/input.rs index d98e885..8a5ba2b 100644 --- a/crates/brioche-core/tests/input.rs +++ b/crates/brioche-core/tests/input.rs @@ -4,6 +4,7 @@ use std::{ sync::Arc, }; +use assert_matches::assert_matches; use brioche_core::{ recipe::{Artifact, Meta}, Brioche, @@ -491,6 +492,252 @@ async fn test_input_dir_with_symlink_resources() -> anyhow::Result<()> { Ok(()) } +#[tokio::test] +async fn test_input_dir_with_external_resources() -> anyhow::Result<()> { + let (brioche, context) = brioche_test_support::brioche_test().await; + + let dir_path = context.mkdir("test").await; + let resource_dir = context.mkdir("resources").await; + let input_resource_dir = context.mkdir("input-resources").await; + + let mut top_file = b"top".to_vec(); + brioche_pack::inject_pack( + &mut top_file, + &brioche_pack::Pack::Metadata { + format: "test".into(), + metadata: vec![], + resource_paths: vec![b"fizz".to_vec()], + }, + )?; + + let mut fizz_file = b"fizz".to_vec(); + brioche_pack::inject_pack( + &mut fizz_file, + &brioche_pack::Pack::Metadata { + format: "test".into(), + metadata: vec![], + resource_paths: vec![b"buzz".to_vec()], + }, + )?; + + context.write_file("test/top", &top_file).await; + context.write_file("resources/fizz", &fizz_file).await; + context.write_file("input-resources/buzz", b"buzz!").await; + + let artifact = create_input_with_resources( + &brioche, + &dir_path, + Some(&resource_dir), + &[input_resource_dir], + false, + ) + .await?; + + assert_eq!( + artifact, + brioche_test_support::dir( + &brioche, + [( + "top", + brioche_test_support::file_with_resources( + brioche_test_support::blob(&brioche, &top_file).await, + false, + brioche_test_support::dir_value( + &brioche, + [( + "fizz", + brioche_test_support::file_with_resources( + brioche_test_support::blob(&brioche, &fizz_file).await, + false, + brioche_test_support::dir_value( + &brioche, + [( + "buzz", + brioche_test_support::file( + brioche_test_support::blob(&brioche, b"buzz!").await, + false + ) + )] + ) + .await + ) + ),] + ) + .await, + ) + ),] + ) + .await + ); + assert!(dir_path.is_dir()); + + Ok(()) +} + +#[tokio::test] +async fn test_input_dir_with_internal_resources() -> anyhow::Result<()> { + let (brioche, context) = brioche_test_support::brioche_test().await; + + let dir_path = context.mkdir("test").await; + let resource_dir = context.mkdir("resources").await; + + let mut top_file = b"top".to_vec(); + brioche_pack::inject_pack( + &mut top_file, + &brioche_pack::Pack::Metadata { + format: "test".into(), + metadata: vec![], + resource_paths: vec![b"fizz".to_vec()], + }, + )?; + + let mut fizz_file = b"fizz".to_vec(); + brioche_pack::inject_pack( + &mut fizz_file, + &brioche_pack::Pack::Metadata { + format: "test".into(), + metadata: vec![], + resource_paths: vec![b"buzz".to_vec()], + }, + )?; + + context.write_file("test/top", &top_file).await; + context.write_file("resources/fizz", &fizz_file).await; + context.write_file("resources/buzz", b"buzz!").await; + + let artifact = + create_input_with_resources(&brioche, &dir_path, Some(&resource_dir), &[], false).await?; + + assert_eq!( + artifact, + brioche_test_support::dir( + &brioche, + [( + "top", + brioche_test_support::file_with_resources( + brioche_test_support::blob(&brioche, &top_file).await, + false, + brioche_test_support::dir_value( + &brioche, + [( + "fizz", + brioche_test_support::file_with_resources( + brioche_test_support::blob(&brioche, &fizz_file).await, + false, + brioche_test_support::dir_value( + &brioche, + [( + "buzz", + brioche_test_support::file( + brioche_test_support::blob(&brioche, b"buzz!").await, + false + ) + )] + ) + .await + ) + ),] + ) + .await, + ) + ),] + ) + .await + ); + assert!(dir_path.is_dir()); + + Ok(()) +} + +#[tokio::test] +async fn test_input_dir_with_recursive_resource_error() -> anyhow::Result<()> { + let (brioche, context) = brioche_test_support::brioche_test().await; + + let dir_path = context.mkdir("test").await; + let resource_dir = context.mkdir("resources").await; + + let mut top_file = b"top".to_vec(); + brioche_pack::inject_pack( + &mut top_file, + &brioche_pack::Pack::Metadata { + format: "test".into(), + metadata: vec![], + resource_paths: vec![b"fizz".to_vec()], + }, + )?; + + let mut fizz_file = b"fizz".to_vec(); + brioche_pack::inject_pack( + &mut fizz_file, + &brioche_pack::Pack::Metadata { + format: "test".into(), + metadata: vec![], + resource_paths: vec![b"fizz".to_vec()], + }, + )?; + + context.write_file("test/top", &top_file).await; + context.write_file("resources/fizz", &fizz_file).await; + + let result = + create_input_with_resources(&brioche, &dir_path, Some(&resource_dir), &[], false).await; + + // Should fail because `fizz` tried to include itself as a resource + assert_matches!(result, Err(_)); + + Ok(()) +} + +#[tokio::test] +async fn test_input_dir_with_mutually_recursive_resource_error() -> anyhow::Result<()> { + let (brioche, context) = brioche_test_support::brioche_test().await; + + let dir_path = context.mkdir("test").await; + let resource_dir = context.mkdir("resources").await; + + let mut top_file = b"top".to_vec(); + brioche_pack::inject_pack( + &mut top_file, + &brioche_pack::Pack::Metadata { + format: "test".into(), + metadata: vec![], + resource_paths: vec![b"fizz".to_vec()], + }, + )?; + + let mut fizz_file = b"fizz".to_vec(); + brioche_pack::inject_pack( + &mut fizz_file, + &brioche_pack::Pack::Metadata { + format: "test".into(), + metadata: vec![], + resource_paths: vec![b"buzz".to_vec()], + }, + )?; + + let mut buzz_file = b"buzz".to_vec(); + brioche_pack::inject_pack( + &mut buzz_file, + &brioche_pack::Pack::Metadata { + format: "test".into(), + metadata: vec![], + resource_paths: vec![b"fizz".to_vec()], + }, + )?; + + context.write_file("test/top", &top_file).await; + context.write_file("resources/fizz", &fizz_file).await; + context.write_file("resources/buzz", &buzz_file).await; + + let result = + create_input_with_resources(&brioche, &dir_path, Some(&resource_dir), &[], false).await; + + // Should fail because `fizz` references `buzz` and `buzz` references `fizz` + assert_matches!(result, Err(_)); + + Ok(()) +} + #[tokio::test] async fn test_input_dir_broken_symlink() -> anyhow::Result<()> { let (brioche, context) = brioche_test_support::brioche_test().await; diff --git a/crates/brioche-test-support/src/lib.rs b/crates/brioche-test-support/src/lib.rs index 778184f..d97e13f 100644 --- a/crates/brioche-test-support/src/lib.rs +++ b/crates/brioche-test-support/src/lib.rs @@ -133,6 +133,9 @@ pub async fn load_rootfs_recipes(brioche: &Brioche, platform: brioche_core::plat Recipe::CollectReferences { recipe } => { recipes.push_back(recipe.value); } + Recipe::AttachResources { recipe } => { + recipes.push_back(recipe.value); + } Recipe::Proxy(_) => unimplemented!(), Recipe::Sync { recipe } => { recipes.push_back(recipe.value);