#![allow(missing_docs)]
use crate::chunking::ObjectMetaSized;
use crate::container::{Config, ExportOpts, ImageReference, Transport};
use crate::objectsource::{ObjectMeta, ObjectSourceMeta};
use crate::prelude::*;
use crate::{gio, glib};
use anyhow::{anyhow, Context, Result};
use camino::{Utf8Component, Utf8Path, Utf8PathBuf};
use cap_std::fs::Dir;
use cap_std_ext::prelude::CapStdExtCommandExt;
use chrono::TimeZone;
use fn_error_context::context;
use once_cell::sync::Lazy;
use ostree::cap_std;
use regex::Regex;
use std::borrow::Cow;
use std::io::Write;
use std::ops::Add;
use std::process::Stdio;
use std::rc::Rc;
use std::sync::Arc;
const OSTREE_GPG_HOME: &[u8] = include_bytes!("fixtures/ostree-gpg-test-home.tar.gz");
const TEST_GPG_KEYID_1: &str = "7FCA23D8472CDAFA";
#[allow(dead_code)]
const TEST_GPG_KEYFPR_1: &str = "5E65DE75AB1C501862D476347FCA23D8472CDAFA";
const TESTREF: &str = "exampleos/x86_64/stable";
#[derive(Debug)]
enum FileDefType {
Regular(Cow<'static, str>),
Symlink(Cow<'static, Utf8Path>),
Directory,
}
#[derive(Debug)]
pub struct FileDef {
uid: u32,
gid: u32,
mode: u32,
path: Cow<'static, Utf8Path>,
ty: FileDefType,
}
impl TryFrom<&'static str> for FileDef {
type Error = anyhow::Error;
fn try_from(value: &'static str) -> Result<Self, Self::Error> {
let mut parts = value.split(' ');
let tydef = parts
.next()
.ok_or_else(|| anyhow!("Missing type definition"))?;
let name = parts.next().ok_or_else(|| anyhow!("Missing file name"))?;
let contents = parts.next();
let contents = move || contents.ok_or_else(|| anyhow!("Missing file contents: {}", value));
if parts.next().is_some() {
anyhow::bail!("Invalid filedef: {}", value);
}
let ty = match tydef {
"r" => FileDefType::Regular(contents()?.into()),
"l" => FileDefType::Symlink(Cow::Borrowed(contents()?.into())),
"d" => FileDefType::Directory,
_ => anyhow::bail!("Invalid filedef type: {}", value),
};
Ok(FileDef {
uid: 0,
gid: 0,
mode: 0o644,
path: Cow::Borrowed(name.into()),
ty,
})
}
}
fn parse_mode(line: &str) -> Result<(u32, u32, u32)> {
let mut parts = line.split(' ').skip(1);
let uid = if let Some(u) = parts.next() {
u
} else {
return Ok((0, 0, 0o644));
};
let gid = parts.next().ok_or_else(|| anyhow!("Missing gid"))?;
let mode = parts.next().ok_or_else(|| anyhow!("Missing mode"))?;
if parts.next().is_some() {
anyhow::bail!("Invalid mode: {}", line);
}
Ok((uid.parse()?, gid.parse()?, u32::from_str_radix(mode, 8)?))
}
impl FileDef {
pub fn iter_from(defs: &'static str) -> impl Iterator<Item = Result<FileDef>> {
let mut uid = 0;
let mut gid = 0;
let mut mode = 0o644;
defs.lines()
.filter(|v| !(v.is_empty() || v.starts_with('#')))
.filter_map(move |line| {
if line.starts_with('m') {
match parse_mode(line) {
Ok(r) => {
uid = r.0;
gid = r.1;
mode = r.2;
None
}
Err(e) => Some(Err(e)),
}
} else {
Some(FileDef::try_from(line).map(|mut def| {
def.uid = uid;
def.gid = gid;
def.mode = mode;
def
}))
}
})
}
}
static OWNERS: Lazy<Vec<(Regex, &str)>> = Lazy::new(|| {
[
("usr/lib/modules/.*/initramfs", "initramfs"),
("usr/lib/modules", "kernel"),
("usr/bin/(ba)?sh", "bash"),
("usr/lib.*/emptyfile.*", "bash"),
("usr/bin/hardlink.*", "testlink"),
("usr/etc/someconfig.conf", "someconfig"),
("usr/etc/polkit.conf", "a-polkit-config"),
("usr/lib/pkgdb", "pkgdb"),
("usr/lib/sysimage/pkgdb", "pkgdb"),
]
.iter()
.map(|(k, v)| (Regex::new(k).unwrap(), *v))
.collect()
});
static CONTENTS_V0: &str = indoc::indoc! { r##"
r usr/lib/modules/5.10.18-200.x86_64/vmlinuz this-is-a-kernel
r usr/lib/modules/5.10.18-200.x86_64/initramfs this-is-an-initramfs
m 0 0 755
r usr/bin/bash the-bash-shell
l usr/bin/sh bash
m 0 0 644
# Some empty files
r usr/lib/emptyfile
r usr/lib64/emptyfile2
# Should be the same object
r usr/bin/hardlink-a testlink
r usr/bin/hardlink-b testlink
r usr/etc/someconfig.conf someconfig
m 10 10 644
r usr/etc/polkit.conf a-polkit-config
m 0 0 644
# See https://github.com/coreos/fedora-coreos-tracker/issues/1258
r usr/lib/sysimage/pkgdb some-package-database
r usr/lib/pkgdb/pkgdb some-package-database
m
d boot
d run
m 0 0 1755
d tmp
"## };
pub const CONTENTS_CHECKSUM_V0: &str =
"5e41de82f9f861fa51e53ce6dd640a260e4fb29b7657f5a3f14157e93d2c0659";
pub static CONTENTS_V0_LEN: Lazy<usize> = Lazy::new(|| OWNERS.len().checked_sub(1).unwrap());
#[derive(Debug, PartialEq, Eq)]
enum SeLabel {
Root,
Usr,
UsrLibSystemd,
Boot,
Etc,
EtcSystemConf,
}
impl SeLabel {
pub fn from_path(p: &Utf8Path) -> Self {
let rootdir = p.components().find_map(|v| {
if let Utf8Component::Normal(name) = v {
Some(name)
} else {
None
}
});
let rootdir = if let Some(r) = rootdir {
r
} else {
return SeLabel::Root;
};
if rootdir == "usr" {
if p.as_str().contains("systemd") {
SeLabel::UsrLibSystemd
} else {
SeLabel::Usr
}
} else if rootdir == "boot" {
SeLabel::Boot
} else if rootdir == "etc" {
if p.as_str().len() % 2 == 0 {
SeLabel::Etc
} else {
SeLabel::EtcSystemConf
}
} else {
SeLabel::Usr
}
}
pub fn to_str(&self) -> &'static str {
match self {
SeLabel::Root => "system_u:object_r:root_t:s0",
SeLabel::Usr => "system_u:object_r:usr_t:s0",
SeLabel::UsrLibSystemd => "system_u:object_r:systemd_unit_file_t:s0",
SeLabel::Boot => "system_u:object_r:boot_t:s0",
SeLabel::Etc => "system_u:object_r:etc_t:s0",
SeLabel::EtcSystemConf => "system_u:object_r:system_conf_t:s0",
}
}
pub fn new_xattrs(&self) -> glib::Variant {
vec![("security.selinux".as_bytes(), self.to_str().as_bytes())].to_variant()
}
}
pub fn create_dirmeta(path: &Utf8Path, selinux: bool) -> glib::Variant {
let finfo = gio::FileInfo::new();
finfo.set_attribute_uint32("unix::uid", 0);
finfo.set_attribute_uint32("unix::gid", 0);
finfo.set_attribute_uint32("unix::mode", libc::S_IFDIR | 0o755);
let label = if selinux {
Some(SeLabel::from_path(path))
} else {
None
};
let xattrs = label.map(|v| v.new_xattrs());
ostree::create_directory_metadata(&finfo, xattrs.as_ref()).unwrap()
}
pub fn require_dirmeta(repo: &ostree::Repo, path: &Utf8Path, selinux: bool) -> Result<String> {
let v = create_dirmeta(path, selinux);
let r = repo.write_metadata(
ostree::ObjectType::DirMeta,
None,
&v,
gio::Cancellable::NONE,
)?;
Ok(r.to_hex())
}
fn ensure_parent_dirs(
mt: &ostree::MutableTree,
path: &Utf8Path,
metadata_checksum: &str,
) -> Result<ostree::MutableTree> {
let parts = relative_path_components(path)
.map(|s| s.as_str())
.collect::<Vec<_>>();
mt.ensure_parent_dirs(&parts, metadata_checksum)
.map_err(Into::into)
}
fn relative_path_components(p: &Utf8Path) -> impl Iterator<Item = Utf8Component> {
p.components()
.filter(|p| matches!(p, Utf8Component::Normal(_)))
}
fn build_mapping_recurse(
path: &mut Utf8PathBuf,
dir: &gio::File,
ret: &mut ObjectMeta,
) -> Result<()> {
use std::collections::btree_map::Entry;
let cancellable = gio::Cancellable::NONE;
let e = dir.enumerate_children(
"standard::name,standard::type",
gio::FileQueryInfoFlags::NOFOLLOW_SYMLINKS,
cancellable,
)?;
for child in e {
let childi = child?;
let name: Utf8PathBuf = childi.name().try_into()?;
let child = dir.child(&name);
path.push(&name);
match childi.file_type() {
gio::FileType::Regular | gio::FileType::SymbolicLink => {
let child = child.downcast::<ostree::RepoFile>().unwrap();
let owner = OWNERS
.iter()
.find_map(|(r, owner)| {
if r.is_match(path.as_str()) {
Some(Rc::from(*owner))
} else {
None
}
})
.ok_or_else(|| anyhow!("Unowned path {}", path))?;
if !ret.set.contains(&*owner) {
ret.set.insert(ObjectSourceMeta {
identifier: Rc::clone(&owner),
name: Rc::clone(&owner),
srcid: Rc::clone(&owner),
change_time_offset: u32::MAX,
});
}
let checksum = child.checksum().unwrap().to_string();
match ret.map.entry(checksum) {
Entry::Vacant(v) => {
v.insert(owner);
}
Entry::Occupied(v) => {
let prev_owner = v.get();
if **prev_owner != *owner {
anyhow::bail!(
"Duplicate object ownership {} ({} and {})",
path.as_str(),
prev_owner,
owner
);
}
}
}
}
gio::FileType::Directory => {
build_mapping_recurse(path, &child, ret)?;
}
o => anyhow::bail!("Unhandled file type: {}", o),
}
path.pop();
}
Ok(())
}
#[derive(Debug)]
pub struct Fixture {
tempdir: tempfile::TempDir,
pub dir: Arc<Dir>,
pub path: Utf8PathBuf,
srcrepo: ostree::Repo,
destrepo: ostree::Repo,
pub selinux: bool,
}
impl Fixture {
#[context("Initializing fixture")]
pub fn new_base() -> Result<Self> {
let tempdir = tempfile::tempdir_in("/var/tmp")?;
let dir = Arc::new(cap_std::fs::Dir::open_ambient_dir(
tempdir.path(),
cap_std::ambient_authority(),
)?);
let path: &Utf8Path = tempdir.path().try_into().unwrap();
let path = path.to_path_buf();
dir.create_dir("src")?;
let srcdir_dfd = &dir.open_dir("src")?;
let gpgtarname = "gpghome.tgz";
srcdir_dfd.write(gpgtarname, OSTREE_GPG_HOME)?;
let gpgtar = srcdir_dfd.open(gpgtarname)?;
srcdir_dfd.remove_file(gpgtarname)?;
srcdir_dfd.create_dir("gpghome")?;
let gpghome = srcdir_dfd.open_dir("gpghome")?;
let st = std::process::Command::new("tar")
.cwd_dir(gpghome)
.stdin(Stdio::from(gpgtar))
.args(&["-azxf", "-"])
.status()?;
assert!(st.success());
let srcrepo =
ostree::Repo::create_at_dir(srcdir_dfd, "repo", ostree::RepoMode::Archive, None)
.context("Creating src/ repo")?;
dir.create_dir("dest")?;
let destrepo =
ostree::Repo::create_at_dir(&dir, "dest/repo", ostree::RepoMode::BareUser, None)?;
Ok(Self {
tempdir,
dir,
path,
srcrepo,
destrepo,
selinux: true,
})
}
pub fn srcrepo(&self) -> &ostree::Repo {
&self.srcrepo
}
pub fn destrepo(&self) -> &ostree::Repo {
&self.destrepo
}
pub fn clear_destrepo(&self) -> Result<()> {
self.destrepo()
.set_ref_immediate(None, self.testref(), None, gio::Cancellable::NONE)?;
self.destrepo()
.prune(ostree::RepoPruneFlags::REFS_ONLY, 0, gio::Cancellable::NONE)?;
Ok(())
}
pub fn write_filedef(&self, root: &ostree::MutableTree, def: &FileDef) -> Result<()> {
let parent_path = def.path.parent();
let parent = if let Some(parent_path) = parent_path {
let meta = require_dirmeta(&self.srcrepo, parent_path, self.selinux)?;
Some(ensure_parent_dirs(root, &def.path, meta.as_str())?)
} else {
None
};
let parent = parent.as_ref().unwrap_or(root);
let name = def.path.file_name().expect("file name");
let label = if self.selinux {
Some(SeLabel::from_path(&def.path))
} else {
None
};
let xattrs = label.map(|v| v.new_xattrs());
let xattrs = xattrs.as_ref();
let checksum = match &def.ty {
FileDefType::Regular(contents) => self.srcrepo.write_regfile_inline(
None,
0,
0,
libc::S_IFREG | def.mode,
xattrs,
contents.as_bytes(),
gio::Cancellable::NONE,
)?,
FileDefType::Symlink(target) => self.srcrepo.write_symlink(
None,
def.uid,
def.gid,
xattrs,
target.as_str(),
gio::Cancellable::NONE,
)?,
FileDefType::Directory => {
let d = parent.ensure_dir(name)?;
let meta = require_dirmeta(&self.srcrepo, &def.path, self.selinux)?;
d.set_metadata_checksum(meta.as_str());
return Ok(());
}
};
parent.replace_file(name, checksum.as_str())?;
Ok(())
}
pub fn commit_filedefs(&self, defs: impl IntoIterator<Item = Result<FileDef>>) -> Result<()> {
let root = ostree::MutableTree::new();
let cancellable = gio::Cancellable::NONE;
let tx = self.srcrepo.auto_transaction(cancellable)?;
for def in defs {
let def = def?;
self.write_filedef(&root, &def)?;
}
let root = self.srcrepo.write_mtree(&root, cancellable)?;
let root = root.downcast_ref::<ostree::RepoFile>().unwrap();
let ts = chrono::DateTime::parse_from_rfc2822("Fri, 29 Aug 1997 10:30:42 PST")?.timestamp();
let metadata = glib::VariantDict::new(None);
metadata.insert(
"buildsys.checksum",
&"41af286dc0b172ed2f1ca934fd2278de4a1192302ffa07087cea2682e7d372e3",
);
metadata.insert("ostree.container-cmd", &vec!["/usr/bin/bash"]);
metadata.insert("version", &"42.0");
metadata.insert(*ostree::METADATA_KEY_BOOTABLE, &true);
let metadata = metadata.to_variant();
let commit = self.srcrepo.write_commit_with_time(
None,
None,
None,
Some(&metadata),
root,
ts as u64,
cancellable,
)?;
self.srcrepo
.transaction_set_ref(None, self.testref(), Some(commit.as_str()));
tx.commit(cancellable)?;
let detached = glib::VariantDict::new(None);
detached.insert("my-detached-key", &"my-detached-value");
let detached = detached.to_variant();
self.srcrepo.write_commit_detached_metadata(
commit.as_str(),
Some(&detached),
gio::Cancellable::NONE,
)?;
let gpghome = self.path.join("src/gpghome");
self.srcrepo.sign_commit(
&commit,
TEST_GPG_KEYID_1,
Some(gpghome.as_str()),
gio::Cancellable::NONE,
)?;
Ok(())
}
pub fn new_v1() -> Result<Self> {
let r = Self::new_base()?;
r.commit_filedefs(FileDef::iter_from(CONTENTS_V0))?;
Ok(r)
}
pub fn testref(&self) -> &'static str {
TESTREF
}
#[context("Updating test repo")]
pub fn update(
&mut self,
additions: impl Iterator<Item = Result<FileDef>>,
removals: impl Iterator<Item = Cow<'static, Utf8Path>>,
) -> Result<()> {
let cancellable = gio::Cancellable::NONE;
let rev = &self.srcrepo().require_rev(self.testref())?;
let (commit, _) = self.srcrepo.load_commit(rev)?;
let metadata = commit.child_value(0);
let root = ostree::MutableTree::from_commit(self.srcrepo(), rev)?;
let ts = chrono::Utc
.timestamp_opt(ostree::commit_get_timestamp(&commit) as i64, 0)
.single()
.unwrap();
let new_ts = ts.add(chrono::Duration::days(1)).timestamp() as u64;
let tx = self.srcrepo.auto_transaction(cancellable)?;
for def in additions {
let def = def?;
self.write_filedef(&root, &def)?;
}
for removal in removals {
let filename = removal
.file_name()
.ok_or_else(|| anyhow!("Invalid path {}", removal))?;
let p = relative_path_components(&removal);
let parts = p.map(|s| s.as_str()).collect::<Vec<_>>();
let parent = &root.walk(&parts, 0)?;
parent.remove(filename, false)?;
self.srcrepo.write_mtree(parent, cancellable)?;
}
let root = self
.srcrepo
.write_mtree(&root, cancellable)
.context("Writing mtree")?;
let root = root.downcast_ref::<ostree::RepoFile>().unwrap();
let commit = self
.srcrepo
.write_commit_with_time(
Some(rev),
None,
None,
Some(&metadata),
root,
new_ts,
cancellable,
)
.context("Writing commit")?;
self.srcrepo
.transaction_set_ref(None, self.testref(), Some(commit.as_str()));
tx.commit(cancellable)?;
Ok(())
}
pub fn get_object_meta(&self) -> Result<crate::objectsource::ObjectMeta> {
let cancellable = gio::Cancellable::NONE;
let root = self.srcrepo.read_commit(self.testref(), cancellable)?.0;
let mut ret = ObjectMeta::default();
build_mapping_recurse(&mut Utf8PathBuf::from("/"), &root, &mut ret)?;
Ok(ret)
}
pub fn into_tempdir(self) -> tempfile::TempDir {
self.tempdir
}
#[context("Exporting tar")]
pub fn export_tar(&self) -> Result<&'static Utf8Path> {
let cancellable = gio::Cancellable::NONE;
let (_, rev) = self.srcrepo.read_commit(self.testref(), cancellable)?;
let path = "exampleos-export.tar";
let mut outf = std::io::BufWriter::new(self.dir.create(path)?);
#[allow(clippy::needless_update)]
let options = crate::tar::ExportOptions {
..Default::default()
};
crate::tar::export_commit(&self.srcrepo, rev.as_str(), &mut outf, Some(options))?;
outf.flush()?;
Ok(path.into())
}
#[context("Exporting container")]
pub async fn export_container(&self) -> Result<(ImageReference, String)> {
let name = "oci-v1";
let container_path = &self.path.join(name);
if container_path.exists() {
std::fs::remove_dir_all(container_path)?;
}
let imgref = ImageReference {
transport: Transport::OciDir,
name: container_path.as_str().to_string(),
};
let config = Config {
labels: Some(
[("foo", "bar"), ("test", "value")]
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
),
..Default::default()
};
let contentmeta = self.get_object_meta().context("Computing object meta")?;
let contentmeta = ObjectMetaSized::compute_sizes(self.srcrepo(), contentmeta)
.context("Computing sizes")?;
let opts = ExportOpts::default();
let digest = crate::container::encapsulate(
self.srcrepo(),
self.testref(),
&config,
Some(opts),
Some(contentmeta),
&imgref,
)
.await
.context("exporting")?;
Ok((imgref, digest))
}
}