From f0092f86a846b47d23a0e992e167712c45f20a93 Mon Sep 17 00:00:00 2001 From: Colin Walters Date: Fri, 24 May 2024 15:24:09 -0400 Subject: [PATCH] rust: Add a composefs-oci crate The high level goal of this crate is to be an opinionated generic storage layer using composefs, with direct support for OCI. Note not just OCI *containers* but also including OCI artifacts too. This crate is intended to be the successor to the "storage core" of both ostree and containers/storage. Signed-off-by: Colin Walters --- Cargo.toml | 2 +- libcomposefs/lcfs-writer.c | 20 ++ libcomposefs/lcfs-writer.h | 1 + rust/composefs-core/src/dumpfile.rs | 6 + rust/composefs-core/src/fsverity.rs | 26 ++ rust/composefs-oci/Cargo.toml | 23 ++ rust/composefs-oci/src/fileutils.rs | 153 ++++++++++ rust/composefs-oci/src/lib.rs | 54 ++++ rust/composefs-oci/src/pull.rs | 17 ++ rust/composefs-oci/src/repo.rs | 439 ++++++++++++++++++++++++++++ rust/composefs-sys/src/lib.rs | 1 + tools/mkcomposefs.c | 20 +- 12 files changed, 742 insertions(+), 20 deletions(-) create mode 100644 rust/composefs-oci/Cargo.toml create mode 100644 rust/composefs-oci/src/fileutils.rs create mode 100644 rust/composefs-oci/src/lib.rs create mode 100644 rust/composefs-oci/src/pull.rs create mode 100644 rust/composefs-oci/src/repo.rs diff --git a/Cargo.toml b/Cargo.toml index fbbdc04d..48ff1fa9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["rust/composefs-core", "rust/composefs-sys"] +members = ["rust/composefs-sys", "rust/composefs-core", "rust/composefs-oci"] resolver = "2" [profile.dev] diff --git a/libcomposefs/lcfs-writer.c b/libcomposefs/lcfs-writer.c index 573f90a1..3b65af83 100644 --- a/libcomposefs/lcfs-writer.c +++ b/libcomposefs/lcfs-writer.c @@ -635,6 +635,26 @@ static int read_content(int fd, size_t size, uint8_t *buf) return 0; } +// Given a file descriptor, enable fsverity. +int lcfs_fd_enable_fsverity(int fd) +{ + struct fsverity_enable_arg arg = {}; + + arg.version = 1; + arg.hash_algorithm = FS_VERITY_HASH_ALG_SHA256; + arg.block_size = 4096; + arg.salt_size = 0; + arg.salt_ptr = 0; + arg.sig_size = 0; + arg.sig_ptr = 0; + + if (ioctl(fd, FS_IOC_ENABLE_VERITY, &arg) != 0) { + return -errno; + } + return 0; +} + + static void digest_to_path(const uint8_t *csum, char *buf) { static const char hexchars[] = "0123456789abcdef"; diff --git a/libcomposefs/lcfs-writer.h b/libcomposefs/lcfs-writer.h index 756e0e2d..10927fb7 100644 --- a/libcomposefs/lcfs-writer.h +++ b/libcomposefs/lcfs-writer.h @@ -157,5 +157,6 @@ LCFS_EXTERN int lcfs_fd_get_fsverity(uint8_t *digest, int fd); LCFS_EXTERN int lcfs_node_set_from_content(struct lcfs_node_s *node, int dirfd, const char *fname, int buildflags); +LCFS_EXTERN int lcfs_fd_enable_fsverity(int fd); #endif diff --git a/rust/composefs-core/src/dumpfile.rs b/rust/composefs-core/src/dumpfile.rs index 1484e635..2a6d2f4c 100644 --- a/rust/composefs-core/src/dumpfile.rs +++ b/rust/composefs-core/src/dumpfile.rs @@ -153,6 +153,11 @@ fn unescape_to_osstr(s: &str) -> Result> { Ok(r) } +fn basic_path_validation(p: &Path) -> Result<()> { + anyhow::ensure!(p.is_absolute()); + Ok(()) +} + /// Unescape a string into a Rust `Path` which is really just an alias for a byte array, /// although there is an implicit assumption that there are no embedded `NUL` bytes. fn unescape_to_path(s: &str) -> Result> { @@ -220,6 +225,7 @@ impl<'p> Entry<'p> { let mut components = s.split(' '); let mut next = |name: &str| components.next().ok_or_else(|| anyhow!("Missing {name}")); let path = unescape_to_path(next("path")?)?; + basic_path_validation(&path)?; let size = u64::from_str(next("size")?)?; let modeval = next("mode")?; let (is_hardlink, mode) = if let Some((_, rest)) = modeval.split_once('@') { diff --git a/rust/composefs-core/src/fsverity.rs b/rust/composefs-core/src/fsverity.rs index a8c6614f..8f85a6cb 100644 --- a/rust/composefs-core/src/fsverity.rs +++ b/rust/composefs-core/src/fsverity.rs @@ -34,6 +34,32 @@ pub fn fsverity_digest_from_fd(fd: BorrowedFd, digest: &mut Digest) -> std::io:: } } +/// Enable fsverity on the provided fd +#[allow(unsafe_code)] +pub fn fsverity_enable(fd: BorrowedFd) -> std::io::Result<()> { + unsafe { map_result(composefs_sys::lcfs_fd_enable_fsverity(fd.as_raw_fd())) } +} + +/// Try to enable fsverity on the provided fd; returns `true` +/// if the fd already had fsverity enabled or it was successfully +/// enabled. Returns `false` if the kernel or filesystem does not support it. +#[allow(unsafe_code)] +pub fn try_fsverity_enable(fd: BorrowedFd) -> std::io::Result { + match unsafe { map_result(composefs_sys::lcfs_fd_enable_fsverity(fd.as_raw_fd())) } { + Ok(()) => Ok(true), + Err(e) => { + let errno = e.raw_os_error().unwrap_or(libc::ENOSYS); + match errno { + // The file already has fsverity enabled + libc::EEXIST => Ok(true), + // The kernel or filesystem doesn't support it + libc::EOPNOTSUPP | libc::ENOTTY => Ok(false), + _ => Err(e), + } + } + } +} + #[cfg(test)] mod tests { use anyhow::Result; diff --git a/rust/composefs-oci/Cargo.toml b/rust/composefs-oci/Cargo.toml new file mode 100644 index 00000000..f3802816 --- /dev/null +++ b/rust/composefs-oci/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "composefs-oci" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = "1.0" +bincode = { version = "1.3.3" } +containers-image-proxy = "0.5.5" +composefs = { path = "../composefs-core" } +cap-std-ext = "4.0" +camino = "1" +clap = { version= "4.2", features = ["derive"] } +fn-error-context = "0.2.0" +rustix = { version = "0.38.34", features = ["fs"] } +libc = "0.2" +serde = "1" +tar = "0.4.38" +tokio = { features = ["io-std", "time", "process", "rt", "net"], version = ">= 1.13.0" } +tokio-util = { features = ["io-util"], version = "0.7" } +tokio-stream = { features = ["sync"], version = "0.1.8" } +hex = "0.4.3" +serde_json = "1.0.117" diff --git a/rust/composefs-oci/src/fileutils.rs b/rust/composefs-oci/src/fileutils.rs new file mode 100644 index 00000000..7fbb62d6 --- /dev/null +++ b/rust/composefs-oci/src/fileutils.rs @@ -0,0 +1,153 @@ +use std::io; +use std::path::Path; + +use anyhow::Result; +use cap_std_ext::{ + cap_std::fs::{ + DirBuilder, DirBuilderExt as _, OpenOptions, OpenOptionsExt as _, Permissions, + PermissionsExt as _, + }, + cap_tempfile::TempFile, +}; +use rustix::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd}; + +/// The default permissions set for directories; we assume +/// nothing else should be accessing this content. If you want +/// that, you can chmod() after, or use ACLs. +pub(crate) fn rwx_perms() -> Permissions { + Permissions::from_mode(0o700) +} +/// The default permissions for regular files. Ditto per above. +pub(crate) fn r_perms() -> Permissions { + Permissions::from_mode(0o400) +} + +pub(crate) fn default_dirbuilder() -> DirBuilder { + let mut builder = DirBuilder::new(); + builder.mode(rwx_perms().mode()); + builder +} + +/// For creating a file with the default permissions +pub(crate) fn default_file_create_options() -> OpenOptions { + let mut r = OpenOptions::new(); + r.create(true); + r.mode(r_perms().mode()); + r +} + +/// Given a string, verify it is a single component of a path; it must +/// not contain `/`. +pub(crate) fn validate_single_path_component(s: &str) -> Result<()> { + anyhow::ensure!(!s.contains('/')); + Ok(()) +} + +pub(crate) fn parent_nonempty(p: &Path) -> Option<&Path> { + p.parent().filter(|v| !v.as_os_str().is_empty()) +} + +// Just ensures that path is not absolute, so that it can be passed +// to cap-std APIs. This makes no attempt +// to avoid directory escapes like `../` under the assumption +// that will be handled by a higher level function. +pub(crate) fn ensure_relative_path(path: &Path) -> &Path { + path.strip_prefix("/").unwrap_or(path) +} + +/// Operates on a generic openat fd +pub(crate) fn ensure_dir(fd: BorrowedFd, p: &Path) -> io::Result { + use rustix::fs::AtFlags; + let mode = rwx_perms().mode(); + match rustix::fs::mkdirat(fd, p, rustix::fs::Mode::from_raw_mode(mode)) { + Ok(()) => Ok(true), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + let st = rustix::fs::statat(fd, p, AtFlags::SYMLINK_NOFOLLOW)?; + if !(st.st_mode & libc::S_IFDIR > 0) { + // TODO use https://doc.rust-lang.org/std/io/enum.ErrorKind.html#variant.NotADirectory + // once it's stable. + return Err(io::Error::new(io::ErrorKind::Other, "Found non-directory")); + } + Ok(false) + } + // If we got ENOENT, then loop again, but create the parents + Err(e) => Err(e.into()), + } +} + +/// The cap-std default does not use RESOLVE_IN_ROOT; this does. +/// Additionally for good measure we use NO_MAGICLINKS and NO_XDEV. +/// We never expect to encounter a mounted /proc in our use cases nor +/// any other mountpoints at all really, but still. +pub(crate) fn openat_rooted( + dirfd: BorrowedFd, + path: impl AsRef, +) -> rustix::io::Result { + use rustix::fs::{OFlags, ResolveFlags}; + rustix::fs::openat2( + dirfd, + path.as_ref(), + OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::DIRECTORY, + rustix::fs::Mode::empty(), + ResolveFlags::IN_ROOT | ResolveFlags::NO_MAGICLINKS | ResolveFlags::NO_XDEV, + ) +} + +/// Manual implementation of recursive dir walking using openat2 +pub(crate) fn ensure_dir_recursive(fd: BorrowedFd, p: &Path, init: bool) -> io::Result { + // Optimize the initial case by skipping the recursive calls; + // we just call mkdirat() and no-op if we get EEXIST + if !init { + if let Some(parent) = parent_nonempty(p) { + ensure_dir_recursive(fd, parent, false)?; + } + } + match ensure_dir(fd, p) { + Ok(b) => Ok(b), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => ensure_dir_recursive(fd, p, false), + Err(e) => Err(e), + } +} + +/// Given a cap-std tmpfile, reopen its file in read-only mode. This is +/// needed for fsverity support. +pub(crate) fn reopen_tmpfile_ro(tf: &mut TempFile) -> std::io::Result<()> { + let procpath = format!("/proc/self/fd/{}", tf.as_file().as_fd().as_raw_fd()); + let tf_ro = cap_std_ext::cap_std::fs::File::open_ambient( + procpath, + cap_std_ext::cap_std::ambient_authority(), + )?; + let tf = tf.as_file_mut(); + *tf = tf_ro; + Ok(()) +} + +// pub(crate) fn normalize_path(path: &Utf8Path) -> Result { +// let mut components = path.components().peekable(); +// let r = if !matches!(components.peek(), Some(camino::Utf8Component::RootDir)) { +// [camino::Utf8Component::RootDir] +// .into_iter() +// .chain(components) +// .collect() +// } else { +// components.collect() +// }; +// Ok(r) +// } + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn test_relpath() { + let expected_foobar = "foo/bar"; + let cases = [("foo/bar", expected_foobar), ("/foo/bar", expected_foobar)]; + for (a, b) in cases { + assert_eq!(ensure_relative_path(Path::new(a)), Path::new(b)); + } + let idem = ["./foo/bar", "./foo", "./"]; + for case in idem { + assert_eq!(ensure_relative_path(Path::new(case)), Path::new(case)); + } + } +} diff --git a/rust/composefs-oci/src/lib.rs b/rust/composefs-oci/src/lib.rs new file mode 100644 index 00000000..5f6e74df --- /dev/null +++ b/rust/composefs-oci/src/lib.rs @@ -0,0 +1,54 @@ +use std::ffi::OsString; + +use anyhow::Result; +use camino::Utf8PathBuf; +use clap::Parser; +use pull::cli_pull; + +mod fileutils; +pub mod pull; +pub mod repo; + +/// Options for specifying the repository +#[derive(Debug, Parser)] +pub(crate) struct RepoOpts { + /// Path to the repository + #[clap(long, value_parser)] + repo: Utf8PathBuf, +} + +/// Options for importing a tar archive. +#[derive(Debug, Parser)] +pub(crate) struct PullOpts { + #[clap(flatten)] + repo_opts: RepoOpts, + + /// Image reference + image: String, +} + +/// Toplevel options for extended ostree functionality. +#[derive(Debug, Parser)] +#[clap(name = "ostree-ext")] +#[clap(rename_all = "kebab-case")] +#[allow(clippy::large_enum_variant)] +pub(crate) enum Opt { + /// Pull an image + Pull(PullOpts), +} + +/// Parse the provided arguments and execute. +/// Calls [`clap::Error::exit`] on failure, printing the error message and aborting the program. +pub async fn run_from_iter(args: I) -> Result<()> +where + I: IntoIterator, + I::Item: Into + Clone, +{ + run_from_opt(Opt::parse_from(args)).await +} + +async fn run_from_opt(opt: Opt) -> Result<()> { + match opt { + Opt::Pull(opts) => cli_pull(opts).await, + } +} diff --git a/rust/composefs-oci/src/pull.rs b/rust/composefs-oci/src/pull.rs new file mode 100644 index 00000000..f68cfe8f --- /dev/null +++ b/rust/composefs-oci/src/pull.rs @@ -0,0 +1,17 @@ +use anyhow::Result; + +use crate::PullOpts; + +pub async fn pull( + proxy: &containers_image_proxy::ImageProxy, + img: &containers_image_proxy::OpenedImage, +) -> Result<()> { + todo!() +} + +pub(crate) async fn cli_pull(opts: PullOpts) -> Result<()> { + let proxy = containers_image_proxy::ImageProxy::new().await?; + let img = proxy.open_image(&opts.image).await?; + + todo!() +} diff --git a/rust/composefs-oci/src/repo.rs b/rust/composefs-oci/src/repo.rs new file mode 100644 index 00000000..6febe891 --- /dev/null +++ b/rust/composefs-oci/src/repo.rs @@ -0,0 +1,439 @@ +use std::borrow::Cow; +use std::cell::OnceCell; +use std::io::{self, BufRead, Seek, Write}; +use std::os::fd::AsFd; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::mpsc::SyncSender; +use std::sync::OnceLock; + +use anyhow::{Context, Result}; +use camino::Utf8Path; +use cap_std::fs::Dir; +use cap_std_ext::cap_std; +use cap_std_ext::cap_std::fs::{DirBuilder, DirBuilderExt, PermissionsExt}; +use cap_std_ext::cap_tempfile::{TempDir, TempFile}; +use cap_std_ext::cmdext::CapStdExtCommandExt; +use cap_std_ext::dirext::CapStdExtDirExt; +use composefs::dumpfile::{self, Entry}; +use composefs::fsverity::Digest; +use composefs::mkcomposefs::{self, mkcomposefs}; +use fn_error_context::context; +use rustix::fd::{AsRawFd, BorrowedFd}; +use rustix::fs::AtFlags; +use serde::{Deserialize, Serialize}; + +use crate::fileutils; + +const REPOMETA: &str = "meta.json"; +const OBJECTS: &str = "objects"; +const LAYERS: &str = "layers"; +const IMAGES: &str = "images"; +const TMP: &str = "tmp"; +const LAYER_CFS: &str = "layer.cfs"; +const BOOTID_XATTR: &str = "user.composefs-oci.bootid"; +/// A container including content here is basically trying to +/// do something malicious, so we'll just reject it. +const API_FILESYSTEMS: &[&str] = &["proc", "sys", "dev"]; + +/// The extended attribute we attach with the target metadata +const CFS_ENTRY_META_XATTR: &str = "user.cfs.entry.meta"; +/// This records the virtual number of links (as opposed to +/// the physical, because we may share multiple regular files +/// by hardlinking into the object store). +const CFS_ENTRY_META_NLINK: &str = "user.cfs.entry.nlink"; + +/// +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] +struct RepoMetadata { + // Must currently be 0.1 + version: String, + // Set to true if and only if we detected the filesystem supports fs-verity + // and all objects should have been initialized that way. + verity: bool, +} + +/// This metadata is serialized underneath the `CFS_ENTRY_META_XATTR` +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] +struct OverrideMetadata { + uid: u32, + gid: u32, + mode: u32, + rdev: Option, + xattrs: Vec<(Vec, Vec)>, +} + +fn get_bootid() -> &'static str { + static BOOTID: OnceLock = OnceLock::new(); + let bootid = + BOOTID.get_or_init(|| std::fs::read_to_string("/proc/sys/kernel/random/boot_id").unwrap()); + bootid.as_str() +} + +fn create_entry(h: tar::Header) -> Result> { + // let size = h.size()?; + // let path = &*h.path()?; + // let path = Utf8Path::from_path(path) + // .ok_or_else(|| anyhow::anyhow!("Invalid non-UTF8 path: {path:?}"))?; + // let path: Cow = Cow::Owned(PathBuf::from(".")); + // let mtime = dumpfile::Mtime { + // sec: h.mtime()?, + // nsec: 0, + // }; + // // The data below are stubs, we'll fix it up after + // let nlink = 1; + // let inline_content = None; + // let fsverity_digest = None; + + // use dumpfile::Item; + // let item = match h.entry_type() { + // tar::EntryType::Regular => {} + // tar::EntryType::Link => todo!(), + // tar::EntryType::Symlink => todo!(), + // tar::EntryType::Char => todo!(), + // tar::EntryType::Block => todo!(), + // tar::EntryType::Directory => todo!(), + // tar::EntryType::Fifo => todo!(), + // tar::EntryType::Continuous => todo!(), + // tar::EntryType::GNULongName => todo!(), + // tar::EntryType::GNULongLink => todo!(), + // tar::EntryType::GNUSparse => todo!(), + // tar::EntryType::XGlobalHeader => todo!(), + // tar::EntryType::XHeader => todo!(), + // _ => todo!(), + // }; + + // let entry = Entry { + // path, + // uid: h.uid().context("uid")?.try_into()?, + // gid: h.gid().context("gid")?.try_into()?, + // mode: h.mode().context("mode")?, + // mtime, + // item: todo!(), + // xattrs: todo!(), + // }; + + todo!() +} + +// fn reject_api_filesystem_path(p: &Path) -> Result<()> { +// for part in API_FILESYSTEMS { +// if let Ok(r) = p.strip_prefix(part) { + +// } +// } +// Ok(()) +// } + +#[context("Initializing object dir")] +fn init_object_dir(objects: &Dir) -> Result<()> { + for prefix in 0..=0xFFu8 { + let path = format!("{:02x}", prefix); + objects.ensure_dir_with(path, &fileutils::default_dirbuilder())?; + } + Ok(()) +} + +#[context("Checking fsverity")] +fn test_fsverity_in(d: &Dir) -> Result { + let mut tf = TempFile::new(&d)?; + tf.write_all(b"test")?; + fileutils::reopen_tmpfile_ro(&mut tf)?; + composefs::fsverity::try_fsverity_enable(tf.as_file().as_fd()).map_err(Into::into) +} + +struct ImportContext<'r> { + // Temporary directory for layer import; + // This contains: + // - objects/ Regular file content (not fsync'd yet!) + // - root/ The layer rootfs + workdir: TempDir, + // Handle for objects/ above + tmp_objects: Dir, + // This fd is using openat2 for more complete sandboxing, unlike default + // cap-std which doesn't use RESOLVE_BENEATH which we need to handle absolute + // symlinks. + layer_root: rustix::fd::OwnedFd, + // Statistics + stats: &'r mut ImportLayerStats, +} + +pub struct Repo { + fd: Dir, + bootid: &'static str, + meta: RepoMetadata, +} + +impl Repo { + pub fn init(fd: Dir, require_verity: bool) -> Result { + let supports_verity = test_fsverity_in(&fd)?; + if require_verity && !supports_verity { + anyhow::bail!("Requested fsverity, but target does not support it"); + } + let meta = RepoMetadata { + version: String::from("0.5"), + verity: supports_verity, + }; + if !fd.try_exists(REPOMETA)? { + fd.atomic_replace_with(REPOMETA, |w| { + serde_json::to_writer(w, &meta).map_err(anyhow::Error::msg) + })?; + } + for d in [OBJECTS, LAYERS, IMAGES, TMP] { + fd.ensure_dir_with(d, &fileutils::default_dirbuilder()) + .context(d)?; + } + let objects = fd.open_dir(OBJECTS)?; + init_object_dir(&objects)?; + Self::open(fd) + } + + #[context("Opening composefs-oci repo")] + pub fn open(fd: Dir) -> Result { + let bootid = get_bootid(); + let meta = serde_json::from_reader( + fd.open(REPOMETA) + .map(std::io::BufReader::new) + .context(REPOMETA)?, + )?; + Ok(Self { fd, bootid, meta }) + } + + pub fn has_verity(&self) -> bool { + self.meta.verity + } + + pub fn has_layer(&self, diffid: &str) -> Result { + let layer_path = &format!("{LAYERS}/{diffid}"); + self.fd.try_exists(layer_path).map_err(Into::into) + } + + pub fn import_layer(&self, src: &mut dyn BufRead, diffid: &str) -> Result { + fileutils::validate_single_path_component(diffid).context("validating diffid")?; + let layer_path = &format!("{LAYERS}/{diffid}"); + // If we've already fetched the layer, then assume the caller is forcing a re-import + // to e.g. repair missing files. + if self.fd.try_exists(layer_path)? { + self.fd + .remove_dir_all(layer_path) + .context("removing extant layerdir")?; + } + let mut res = ImportLayerStats::default(); + let global_tmp = &self.fd.open_dir(TMP).context(TMP)?; + let global_objects = &self.fd.open_dir(OBJECTS).context(OBJECTS)?; + let (workdir, tmp_objects) = { + let d = TempDir::new_in(global_tmp)?; + // rustix::fs::fsetxattr( + // d.as_fd(), + // BOOTID_XATTR, + // self.bootid.as_bytes(), + // rustix::fs::XattrFlags::empty(), + // ) + // .context("setting bootid xattr")?; + d.create_dir("root")?; + d.create_dir(OBJECTS)?; + let objects = d.open_dir(OBJECTS)?; + init_object_dir(&objects)?; + (d, objects) + }; + let layer_root = fileutils::openat_rooted(workdir.as_fd(), "root") + .context("Opening sandboxed layer dir")?; + let mut ctx = ImportContext { + workdir, + tmp_objects, + layer_root, + stats: &mut res, + }; + let mut archive = tar::Archive::new(src); + + for entry in archive.entries()? { + let entry = entry?; + + let etype = entry.header().entry_type(); + // Make a copy because it may refer into the header, but we need it + // after we process the entry too. + let path = entry.header().path()?; + if let Some(parent) = fileutils::parent_nonempty(&path) { + fileutils::ensure_dir_recursive(ctx.layer_root.as_fd(), parent, true) + .with_context(|| format!("Creating parents for {path:?}"))?; + }; + + match etype { + tar::EntryType::Regular => { + // Copy as we need to refer to it after processing the entry + let path = path.into_owned(); + self.unpack_regfile(global_objects, &mut ctx, entry, &path)?; + } + tar::EntryType::Link => { + let target = entry + .link_name() + .context("linkname")? + .ok_or_else(|| anyhow::anyhow!("Missing hardlink target"))?; + rustix::fs::linkat( + ctx.layer_root.as_fd(), + &*path, + ctx.layer_root.as_fd(), + &*target, + AtFlags::empty(), + ) + .with_context(|| format!("hardlinking {path:?} to {target:?}"))?; + ctx.stats.meta_count += 1; + } + tar::EntryType::Symlink => { + let target = entry + .link_name() + .context("linkname")? + .ok_or_else(|| anyhow::anyhow!("Missing hardlink target"))?; + rustix::fs::symlinkat(&*target, ctx.layer_root.as_fd(), &*path) + .with_context(|| format!("symlinking {path:?} to {target:?}"))?; + ctx.stats.meta_count += 1; + } + tar::EntryType::Char | tar::EntryType::Block => { + todo!() + } + tar::EntryType::Directory => { + fileutils::ensure_dir(ctx.layer_root.as_fd(), &path)?; + } + tar::EntryType::Fifo => todo!(), + o => anyhow::bail!("Unhandled entry type: {o:?}"), + } + } + + Ok(res) + } + + #[context("Unpacking regfile")] + fn unpack_regfile( + &self, + global_objects: &Dir, + ctx: &mut ImportContext, + mut entry: tar::Entry, + path: &Path, + ) -> Result<()> { + use rustix::fs::AtFlags; + // First, spool the file content to a temporary file + let mut tmpfile = TempFile::new(&ctx.tmp_objects).context("Creating tmpfile")?; + let wrote_size = std::io::copy(&mut entry, &mut tmpfile) + .with_context(|| format!("Copying tar entry {:?} to tmpfile", path))?; + tmpfile.seek(std::io::SeekFrom::Start(0))?; + + // Load metadata + let header = entry.header(); + let size = header.size().context("header size")?; + // This should always be true, but just in case + anyhow::ensure!(size == wrote_size); + + // Compute its composefs digest. This can be an expensive operation, + // so in the future it'd be nice to do this is a helper thread. However + // doing so would significantly complicate the flow. + if self.has_verity() { + fileutils::reopen_tmpfile_ro(&mut tmpfile).context("Reopening tmpfile")?; + composefs::fsverity::fsverity_enable(tmpfile.as_file().as_fd()) + .context("Failed to enable fsverity")?; + }; + let mut digest = Digest::new(); + composefs::fsverity::fsverity_digest_from_fd(tmpfile.as_file().as_fd(), &mut digest) + .context("Computing fsverity digest")?; + let mut buf = hex::encode(digest.get()); + buf.insert(2, '/'); + let exists_globally = global_objects.try_exists(&buf)?; + let exists_locally = !exists_globally && ctx.tmp_objects.try_exists(&buf)?; + if exists_globally { + ctx.stats.extant_objects_count += 1; + ctx.stats.extant_objects_size += size; + rustix::fs::linkat( + ctx.tmp_objects.as_fd(), + &buf, + ctx.layer_root.as_fd(), + path, + AtFlags::empty(), + ) + .with_context(|| format!("Linking extant object {buf} to {path:?}"))?; + } else { + if !exists_locally { + tmpfile.replace(&buf).context("tmpfile replace")?; + ctx.stats.imported_objects_count += 1; + ctx.stats.imported_objects_size += size; + } + rustix::fs::linkat( + ctx.tmp_objects.as_fd(), + &buf, + ctx.layer_root.as_fd(), + path, + AtFlags::empty(), + ) + .with_context(|| format!("Linking tmp object {buf} to {path:?}"))?; + } + + Ok(()) + } +} + +#[derive(Debug, Default)] +pub struct ImportLayerStats { + /// Existing regular file count + extant_objects_count: usize, + /// Existing regular file size + extant_objects_size: u64, + + /// Imported regular file count + imported_objects_count: usize, + /// Imported regular file size + imported_objects_size: u64, + + /// Imported metadata + meta_count: u64, +} + +#[cfg(test)] +mod tests { + use std::{ + io::{BufReader, BufWriter}, + process::Command, + }; + + use super::*; + + #[test] + fn test_repo() -> Result<()> { + let td = TempDir::new(cap_std::ambient_authority())?; + let td = &*td; + + td.create_dir("repo")?; + let repo = Repo::init(td.open_dir("repo")?, false).unwrap(); + eprintln!("verity={}", repo.has_verity()); + + const EMPTY_DIFFID: &str = + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + assert!(!repo.has_layer(EMPTY_DIFFID).unwrap()); + + // A no-op import + let mut buf = std::io::BufReader::new(std::io::Cursor::new(b"")); + let r = repo.import_layer(&mut buf, EMPTY_DIFFID).unwrap(); + assert_eq!(r.extant_objects_count, 0); + assert_eq!(r.imported_objects_count, 0); + assert_eq!(r.imported_objects_size, 0); + + // Serialize our own source code + + let testtar = td.create("test.tar").map(BufWriter::new)?; + let mut testtar = tar::Builder::new(testtar); + testtar.follow_symlinks(false); + testtar + .append_dir_all("./", "../../tests") + .context("creating tar")?; + drop(testtar.into_inner()?.into_inner()?); + let digest_o = Command::new("sha256sum") + .stdin(td.open("test.tar")?) + .stdout(std::process::Stdio::piped()) + .output()?; + assert!(digest_o.status.success()); + let digest = String::from_utf8(digest_o.stdout).unwrap(); + let digest = digest.split_ascii_whitespace().next().unwrap().trim(); + let mut testtar = td.open("test.tar").map(BufReader::new)?; + + repo.import_layer(&mut testtar, digest).unwrap(); + + Ok(()) + } +} diff --git a/rust/composefs-sys/src/lib.rs b/rust/composefs-sys/src/lib.rs index 91016386..d8ab541f 100644 --- a/rust/composefs-sys/src/lib.rs +++ b/rust/composefs-sys/src/lib.rs @@ -5,6 +5,7 @@ extern "C" { digest: *mut u8, fd: std::os::raw::c_int, ) -> std::os::raw::c_int; + pub fn lcfs_fd_enable_fsverity(fd: std::os::raw::c_int) -> std::os::raw::c_int; } /// Convert an integer return value into a `Result`. diff --git a/tools/mkcomposefs.c b/tools/mkcomposefs.c index 5e78db8b..fd0d6645 100644 --- a/tools/mkcomposefs.c +++ b/tools/mkcomposefs.c @@ -702,24 +702,6 @@ static int join_paths(char **out, const char *path1, const char *path2) return asprintf(out, "%.*s%s%s", len, path1, sep, path2); } -static errint_t enable_verity(int fd) -{ - struct fsverity_enable_arg arg = {}; - - arg.version = 1; - arg.hash_algorithm = FS_VERITY_HASH_ALG_SHA256; - arg.block_size = 4096; - arg.salt_size = 0; - arg.salt_ptr = 0; - arg.sig_size = 0; - arg.sig_ptr = 0; - - if (ioctl(fd, FS_IOC_ENABLE_VERITY, &arg) != 0) { - return -errno; - } - return 0; -} - static void cleanup_unlink_freep(void *pp) { char *filename = *(char **)pp; @@ -981,7 +963,7 @@ static int copy_file_with_dirs_if_needed(const char *src, const char *dst_base, } if (fstat(dfd, &statbuf) == 0) { - err = enable_verity(dfd); + err = lcfs_fd_enable_fsverity(dfd); if (err < 0) { /* Ignore errors, we're only trying to enable it */ }