Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -1026,3 +1026,8 @@ Optional delegated cgroup v2 supervision adds aggregate memory, task and CPU
bandwidth limits. An independent pipe watcher kills the group on supervisor death.
Delegation is an explicit administrator operation; unavailable controllers fail
closed and normal per-process rlimits remain in force.

Receipt comparison reports differences in recipe, sources, image, dependencies,
settings and outputs. Offline replay requires approved commits, retained source
inputs and matching image fingerprints, and uses the recorded SOURCE_DATE_EPOCH.
The result is a local comparison, not an independent reproducibility attestation.
73 changes: 69 additions & 4 deletions crates/pacvamp/src/aur/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ use crate::manifest::Settings;
/// How to build.
#[derive(Debug, Clone)]
pub struct BuildOpts {
pub source_date_epoch: Option<i64>,
pub image_sha256: Option<String>,
pub cgroup_root: Option<PathBuf>,
pub cache_lease: std::sync::Arc<nix::fcntl::Flock<std::fs::File>>,
/// Apply the Landlock and seccomp jail to the build phase.
Expand Down Expand Up @@ -66,7 +68,13 @@ impl BuildOpts {
if settings.aur_cgroup_root.is_some() && !settings.aur_jail {
bail!("cgroup builds require the filesystem jail");
}
let image_sha256 = chroot
.as_deref()
.map(super::receipt::image_digest)
.transpose()?;
Ok(BuildOpts {
source_date_epoch: None,
image_sha256,
cgroup_root: settings.aur_cgroup_root.clone(),
cache_lease,
jail: settings.aur_jail,
Expand Down Expand Up @@ -138,7 +146,7 @@ pub fn missing_deps(host: &Host, reviewed: &Reviewed, arch: &str) -> Result<Miss

/// Build `reviewed` at its target commit. Returns the package files.
pub fn build(reviewed: &Reviewed, opts: &BuildOpts) -> Result<Vec<PathBuf>> {
build_with_options(reviewed, opts, false)
build_with_options(reviewed, opts, false, None)
}

/// Bootstrap a reviewed split pkgbase whose sibling closes a dependency
Expand All @@ -148,14 +156,49 @@ pub fn build_without_dependency_checks(
reviewed: &Reviewed,
opts: &BuildOpts,
) -> Result<Vec<PathBuf>> {
build_with_options(reviewed, opts, true)
build_with_options(reviewed, opts, true, None)
}

/// Replay retained source inputs in an image matching the original receipt.
pub fn replay(
reviewed: &Reviewed,
opts: &BuildOpts,
reference: &super::receipt::Receipt,
sources: &Path,
) -> Result<Vec<PathBuf>> {
if reference.pkgbase != reviewed.pkgbase || reference.commit != reviewed.target {
bail!("recipe does not match reference receipt");
}
if reference.build_network
|| reference.source_date_epoch.is_none()
|| reference.image_sha256.is_none()
{
bail!("replay needs an offline-build receipt with a pinned image and source date");
}
if opts.image_sha256 != reference.image_sha256 || opts.dependencies != reference.dependencies {
bail!("build image differs from reference; use aur compare to inspect other builds");
}
let mut opts = opts.clone();
opts.network = false;
opts.source_date_epoch = reference.source_date_epoch;
build_with_options(reviewed, &opts, false, Some((reference, sources)))
}

fn build_with_options(
reviewed: &Reviewed,
opts: &BuildOpts,
without_dependency_checks: bool,
replay: Option<(&super::receipt::Receipt, &Path)>,
) -> Result<Vec<PathBuf>> {
let mut options = opts.clone();
if options.source_date_epoch.is_none() {
options.source_date_epoch = reviewed
.checkout
.log(&reviewed.target, 1)?
.first()
.map(|commit| commit.time);
}
let opts = &options;
let checkout = &reviewed.checkout;
let cache = checkout
.dir
Expand All @@ -176,13 +219,24 @@ fn build_with_options(
for dir in [&opts.srcdest, &opts.builddir, &opts.logdest, &verifydir] {
std::fs::create_dir_all(dir).wrap_err_with(|| format!("creating {}", dir.display()))?;
}
if let Some((reference, sources)) = replay {
if super::receipt::inputs(sources)? != reference.sources
|| super::receipt::vcs_refs(sources)? != reference.vcs_refs
{
bail!("retained source inputs no longer match receipt");
}
copy_tree(sources, &opts.srcdest)?;
}
checkout.export(&reviewed.target, &verifydir.join("worktree"))?;
checkout.export(&reviewed.target, &opts.builddir.join("worktree"))?;

// Phase 1 only downloads and verifies sources. Unlike --nobuild,
// --verifysource does not run prepare() or pkgver() outside the jail.
let verify_args = ["--verifysource", "--noconfirm", "--force"];
let status = run_makepkg(opts, &verify_args, true, true, &verifydir)
let mut verify_args = vec!["--verifysource", "--noconfirm", "--force"];
if replay.is_some() {
verify_args.push("--holdver");
}
let status = run_makepkg(opts, &verify_args, replay.is_none(), true, &verifydir)
.wrap_err("running makepkg --verifysource")?;
if !status.success() {
bail!(
Expand All @@ -196,6 +250,11 @@ fn build_with_options(

let sources = super::receipt::inputs(&opts.srcdest)?;
let refs = super::receipt::vcs_refs(&opts.srcdest)?;
if let Some((reference, _)) = replay
&& (sources != reference.sources || refs != reference.vcs_refs)
{
bail!("verification changed pinned source inputs");
}
// Phase 2 extracts, prepares, builds, and packages inside the jail.
// --holdver prevents makepkg from updating VCS sources a second time;
// phase 1 already fetched and verified the exact source state.
Expand Down Expand Up @@ -426,6 +485,9 @@ fn spawn_makepkg(
if opts.chroot.is_some() {
command.env("PATH", "/usr/bin:/bin");
}
if let Some(epoch) = opts.source_date_epoch {
command.env("SOURCE_DATE_EPOCH", epoch.to_string());
}
set_private_home(&mut command, builddir)?;
if opts.chroot.is_some() {
let run = opts
Expand All @@ -448,6 +510,9 @@ fn spawn_makepkg(

if capture_output {
command.stdout(Stdio::piped()).stderr(Stdio::piped());
} else {
use std::os::fd::AsFd as _;
command.stdout(Stdio::from(std::io::stderr().as_fd().try_clone_to_owned()?));
}
let mut child =
crate::build_process::ManagedChild::new(command.spawn().wrap_err("starting makepkg")?)?;
Expand Down
143 changes: 143 additions & 0 deletions crates/pacvamp/src/aur/receipt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,17 @@ pub struct Reference {

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Input {
#[serde(default)]
pub mode: Option<u32>,
pub sha256: Option<String>,
pub link: Option<PathBuf>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Receipt {
#[serde(default)]
pub source_date_epoch: Option<i64>,
#[serde(default)]
pub image_sha256: Option<String>,
pub schema: u32,
pub claim: String,
pub pkgbase: String,
Expand All @@ -45,6 +51,7 @@ pub fn inputs(root: &Path) -> Result<BTreeMap<PathBuf, Input>> {
out.insert(
path.strip_prefix(root)?.into(),
Input {
mode: None,
sha256: None,
link: Some(std::fs::read_link(path)?),
},
Expand All @@ -53,6 +60,10 @@ pub fn inputs(root: &Path) -> Result<BTreeMap<PathBuf, Input>> {
out.insert(
path.strip_prefix(root)?.into(),
Input {
mode: Some({
use std::os::unix::fs::PermissionsExt as _;
meta.permissions().mode() & 0o7777
}),
sha256: Some(packslip::digest_file(path)?.0),
link: None,
},
Expand Down Expand Up @@ -120,7 +131,14 @@ pub fn write(
.ok_or_else(|| eyre::eyre!("invalid output filename"))?;
outputs.insert(name.into(), packslip::digest_file(file)?.0);
}
if let Some(root) = &opts.chroot
&& Some(image_digest(root)?) != opts.image_sha256
{
bail!("build image changed while building; refusing receipt");
}
let receipt = Receipt {
source_date_epoch: opts.source_date_epoch,
image_sha256: opts.image_sha256.clone(),
schema: 1,
claim: "local observation; not a signed attestation".into(),
pkgbase: reviewed.pkgbase.clone(),
Expand Down Expand Up @@ -182,3 +200,128 @@ pub fn for_artifact(file: &Path) -> Result<(Receipt, Reference)> {
},
))
}

/// Hash the image visible to the builder; private entries retain metadata only.
pub fn image_digest(root: &Path) -> Result<String> {
use sha2::{Digest as _, Sha256};
use std::{io::Read as _, os::unix::fs::MetadataExt as _};
fn visit(
root: &Path,
path: &Path,
out: &mut BTreeMap<PathBuf, serde_json::Value>,
) -> Result<()> {
let meta = std::fs::symlink_metadata(path)?;
let mut value = serde_json::json!({"mode":meta.mode(),"uid":meta.uid(),"gid":meta.gid(),"mtime":meta.mtime(),"mtime_nsec":meta.mtime_nsec()});
if meta.is_symlink() {
value["link"] = serde_json::to_value(std::fs::read_link(path)?)?;
} else if meta.is_file() {
match std::fs::File::open(path) {
Ok(mut file) => {
let mut hash = Sha256::new();
let mut buffer = [0; 65536];
loop {
let n = file.read(&mut buffer)?;
if n == 0 {
break;
}
hash.update(&buffer[..n]);
}
value["sha256"] = format!("{:x}", hash.finalize()).into();
}
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
value["unreadable"] = true.into();
value["size"] = meta.len().into();
}
Err(err) => return Err(err.into()),
}
} else if meta.is_dir() {
match std::fs::read_dir(path) {
Ok(entries) => {
for entry in entries {
visit(root, &entry?.path(), out)?;
}
}
Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
value["unreadable"] = true.into()
}
Err(err) => return Err(err.into()),
}
} else {
// Sockets and device nodes (for example stale GnuPG agent sockets)
// have no stable file contents to hash. Never open them.
value["special"] = true.into();
value["device"] = meta.rdev().into();
}
out.insert(path.strip_prefix(root)?.into(), value);
Ok(())
}
let mut inventory = BTreeMap::new();
for entry in std::fs::read_dir(root)? {
let entry = entry?;
if [
"dev",
"proc",
"sys",
"run",
"tmp",
"build",
"pacvamp-helper",
"pacvamp-cgroup",
]
.iter()
.any(|reserved| entry.file_name() == *reserved)
{
continue;
}
visit(root, &entry.path(), &mut inventory)?;
}
Ok(format!(
"{:x}",
Sha256::digest(serde_json::to_vec(&inventory)?)
))
}

#[derive(Debug, Serialize)]
pub struct Difference {
pub component: String,
pub before: serde_json::Value,
pub after: serde_json::Value,
}
#[derive(Debug, Serialize)]
pub struct Comparison {
pub identical: bool,
pub differences: Vec<Difference>,
pub claim: &'static str,
}
pub fn compare(before: &Receipt, after: &Receipt) -> Result<Comparison> {
let a = serde_json::to_value(before)?;
let b = serde_json::to_value(after)?;
let mut differences = Vec::new();
for key in [
"pkgbase",
"commit",
"source_date_epoch",
"image_sha256",
"jail",
"build_network",
"limits",
"makepkg_sha256",
"dependencies",
"sources",
"vcs_refs",
"outputs",
] {
if a[key] != b[key] {
differences.push(Difference {
component: key.into(),
before: a[key].clone(),
after: b[key].clone(),
});
}
}
Ok(Comparison {
identical: differences.is_empty(),
differences,
claim: "local comparison of recorded inputs and outputs; not an independent reproducibility attestation",
})
}
Loading
Loading