use anyhow::{bail, Context, Result};
use flate2::{Compression, GzBuilder};
use std::io::{Cursor, Read};
use std::path::Path;
use std::process::Command;
fn git(root: &Path, args: &[&str]) -> Result<Vec<u8>> {
let output = Command::new("git").current_dir(root).args(args).output()?;
if !output.status.success() {
bail!("git {} failed", args.join(" "));
}
Ok(output.stdout)
}
#[cfg(test)]
pub fn create(root: &Path, name: &str, version: &str) -> Result<Vec<u8>> {
create_with_lock(root, name, version, None)
}
pub fn create_with_lock(
root: &Path,
name: &str,
version: &str,
lock: Option<&[u8]>,
) -> Result<Vec<u8>> {
if name.is_empty()
|| version.is_empty()
|| !name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|| !version
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b".-+".contains(&b))
{
bail!("invalid package name or version");
}
let top = String::from_utf8(git(root, &["rev-parse", "--show-toplevel"])?)?;
if std::fs::canonicalize(top.trim())? != std::fs::canonicalize(root)? {
bail!("packaging a nested workspace member is not yet supported");
}
if !git(root, &["status", "--porcelain", "--untracked-files=all"])?.is_empty() {
bail!("packaging requires a clean Git checkout");
}
let sha = String::from_utf8(git(root, &["rev-parse", "HEAD"])?)?
.trim()
.to_owned();
let tree = String::from_utf8(git(root, &["ls-tree", "-r", "HEAD"])?)?;
if tree.lines().any(|line| line.starts_with("160000 ")) {
bail!("packaging repositories with submodules is not yet supported");
}
let names = String::from_utf8(git(root, &["ls-tree", "-r", "--name-only", "-z", "HEAD"])?)?;
let manifest_text = std::fs::read_to_string(root.join("Cargo.toml"))?;
let manifest_data: toml::Value = toml::from_str(&manifest_text)?;
let package_data = manifest_data
.get("package")
.and_then(toml::Value::as_table)
.context("missing package table")?;
let rules = package_data
.get("include")
.map(|value| (value, true))
.or_else(|| package_data.get("exclude").map(|value| (value, false)));
let license = package_data
.get("license-file")
.and_then(toml::Value::as_str)
.map(Path::new);
let ignored = if rules.is_some_and(|(_, include)| include) || license.is_some() {
String::from_utf8(git(
root,
&[
"ls-files",
"--others",
"--ignored",
"--exclude-standard",
"-z",
"--",
".",
":(exclude)target/**",
],
)?)?
} else {
String::new()
};
let selected = if let Some((value, include)) = rules {
let patterns: Vec<String> = value
.as_array()
.context("include/exclude must be an array")?
.iter()
.map(|value| {
value
.as_str()
.map(str::to_owned)
.context("package pattern must be a string")
})
.collect::<Result<_>>()?;
let paths: Vec<String> = names
.split('\0')
.chain(ignored.split('\0'))
.filter(|name| !name.is_empty())
.map(str::to_owned)
.collect();
Some(super::package_patterns::select(&paths, &patterns, include)?)
} else {
None
};
let subpackages: Vec<_> = names
.split('\0')
.chain(ignored.split('\0'))
.filter_map(|name| {
let path = Path::new(name);
(path.file_name() == Some(std::ffi::OsStr::new("Cargo.toml")))
.then(|| path.parent())
.flatten()
.filter(|parent| !parent.as_os_str().is_empty())
.map(Path::to_path_buf)
})
.collect();
let bytes = git(root, &["archive", "HEAD"])?;
let mut source = tar::Archive::new(Cursor::new(bytes));
let gzip = GzBuilder::new()
.mtime(0)
.write(Vec::new(), Compression::default());
let mut archive = tar::Builder::new(gzip);
let prefix = format!("{name}-{version}");
let mut manifest = None;
let mut has_lockfile = false;
let mut archived_paths = std::collections::HashSet::new();
for entry in source.entries()? {
let mut entry = entry?;
if entry.header().entry_type().is_dir() || entry.header().entry_type().as_byte() == b'g' {
continue;
}
let symlink = entry.header().entry_type().is_symlink();
if !entry.header().entry_type().is_file() && !symlink {
bail!("unsupported source archive entry");
}
let path = entry.path()?.into_owned();
if path.starts_with("target") || subpackages.iter().any(|parent| path.starts_with(parent)) {
continue;
}
if selected
.as_ref()
.is_some_and(|files| !files.contains(path.to_str().unwrap_or("")))
&& path != Path::new("Cargo.toml")
&& path != Path::new("Cargo.lock")
&& license != Some(path.as_path())
{
continue;
}
archived_paths.insert(path.clone());
if path == Path::new("Cargo.lock") {
has_lockfile = true;
}
if path == Path::new("Cargo.toml.orig") || path == Path::new(".cargo_vcs_info.json") {
bail!("source contains reserved package path");
}
let mut contents = Vec::new();
let mut file_mode = None;
if symlink {
let resolved = std::fs::canonicalize(root.join(&path))?;
let relative = resolved
.strip_prefix(std::fs::canonicalize(root)?)
.context("package symlink points outside the repository")?;
if !resolved.is_file() {
bail!("package symlink must point to a tracked file");
}
let relative = relative.to_str().context("non-UTF-8 symlink target")?;
contents = git(root, &["show", &format!("{sha}:{relative}")])?;
if contents != std::fs::read(&resolved)? {
bail!("symlink target differs from committed source");
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
file_mode = Some(std::fs::metadata(&resolved)?.permissions().mode() & 0o777);
}
} else {
entry.read_to_end(&mut contents)?;
}
if path == Path::new("Cargo.lock") {
if contents != std::fs::read(root.join("Cargo.lock"))? {
bail!("working lockfile differs from the committed lockfile");
}
if let Some(expected) = lock {
contents = expected.to_vec();
}
}
if path == Path::new("Cargo.toml") {
manifest = Some(contents.clone());
let mut normalized: toml::Value = toml::from_str(std::str::from_utf8(&contents)?)?;
let table = normalized
.as_table_mut()
.context("manifest must be a table")?;
for section in ["workspace", "patch", "replace"] {
table.remove(section);
}
contents = toml::to_string(&normalized)?.into_bytes();
}
let mut header = if symlink {
tar::Header::new_gnu()
} else {
entry.header().clone()
};
if symlink {
header.set_entry_type(tar::EntryType::Regular);
header.set_mode(file_mode.unwrap_or(0o644));
}
header.set_uid(0);
header.set_gid(0);
header.set_mtime(0);
header.set_size(contents.len() as u64);
header.set_cksum();
archive.append_data(
&mut header,
Path::new(&prefix).join(path),
contents.as_slice(),
)?;
}
for name in names.split('\0').filter(|name| !name.is_empty()) {
let path = Path::new(name);
if path.starts_with("target") || subpackages.iter().any(|parent| path.starts_with(parent)) {
continue;
}
let required = selected.as_ref().is_none_or(|files| files.contains(name))
|| path == Path::new("Cargo.toml")
|| path == Path::new("Cargo.lock")
|| license == Some(path);
if required && !archived_paths.contains(path) {
if path == Path::new("Cargo.toml.orig") || path == Path::new(".cargo_vcs_info.json") {
bail!("source contains reserved package path");
}
let source_path = root.join(path);
let source_meta = std::fs::symlink_metadata(&source_path)?;
let resolved = if source_meta.file_type().is_symlink() {
let resolved = std::fs::canonicalize(&source_path)?;
resolved
.strip_prefix(std::fs::canonicalize(root)?)
.context("package symlink points outside the repository")?;
resolved
} else if source_meta.file_type().is_file() {
std::fs::canonicalize(&source_path)?
} else {
bail!("Git omitted a non-file package entry: {name}");
};
if !resolved.is_file() {
bail!("package symlink must point to a tracked file");
}
let relative = resolved
.strip_prefix(std::fs::canonicalize(root)?)
.context("package entry points outside the repository")?
.to_str()
.context("non-UTF-8 package path")?;
let mut content = git(root, &["show", &format!("{sha}:{relative}")])?;
if content != std::fs::read(&resolved)? {
bail!("package file differs from committed source: {name}");
}
if path == Path::new("Cargo.toml") {
manifest = Some(content.clone());
let mut normalized: toml::Value = toml::from_str(std::str::from_utf8(&content)?)?;
let table = normalized
.as_table_mut()
.context("manifest must be a table")?;
for section in ["workspace", "patch", "replace"] {
table.remove(section);
}
content = toml::to_string(&normalized)?.into_bytes();
}
if path == Path::new("Cargo.lock") {
if let Some(expected) = lock {
content = expected.to_vec();
}
has_lockfile = true;
}
let mut header = tar::Header::new_gnu();
#[cfg(unix)]
let mode = {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(&resolved)?.permissions().mode() & 0o777
};
#[cfg(not(unix))]
let mode = 0o644;
header.set_size(content.len() as u64);
header.set_mode(mode);
header.set_mtime(0);
header.set_cksum();
archive.append_data(
&mut header,
Path::new(&prefix).join(path),
content.as_slice(),
)?;
}
}
for name in ignored.split('\0').filter(|name| !name.is_empty()) {
let path = Path::new(name);
if path.starts_with("target")
|| subpackages.iter().any(|parent| path.starts_with(parent))
|| path == Path::new("Cargo.lock")
|| path == Path::new("Cargo.toml")
|| path == Path::new("Cargo.toml.orig")
|| path == Path::new(".cargo_vcs_info.json")
{
continue;
}
let included = rules.is_some_and(|(_, include)| include)
&& selected.as_ref().is_some_and(|files| files.contains(name));
if !included && license != Some(path) {
continue;
}
let resolved = std::fs::canonicalize(root.join(path))?;
if !resolved.starts_with(std::fs::canonicalize(root)?) || !resolved.is_file() {
bail!("included ignored path must resolve to a file inside the package: {name}");
}
let content = std::fs::read(&resolved)?;
let mut header = tar::Header::new_gnu();
#[cfg(unix)]
let mode = {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(&resolved)?.permissions().mode() & 0o777
};
#[cfg(not(unix))]
let mode = 0o644;
header.set_size(content.len() as u64);
header.set_mode(mode);
header.set_mtime(0);
header.set_cksum();
archive.append_data(
&mut header,
Path::new(&prefix).join(path),
content.as_slice(),
)?;
}
let manifest = manifest.context("missing root Cargo.toml")?;
if !has_lockfile {
let bytes = lock.context("missing package lockfile")?;
let mut header = tar::Header::new_gnu();
header.set_size(bytes.len() as u64);
header.set_mode(0o644);
header.set_mtime(0);
header.set_cksum();
archive.append_data(&mut header, Path::new(&prefix).join("Cargo.lock"), bytes)?;
}
if std::fs::read(root.join("Cargo.toml"))? != manifest {
bail!("working manifest differs from the committed package manifest");
}
let parsed: toml::Value = toml::from_str(std::str::from_utf8(&manifest)?)?;
let package = parsed
.get("package")
.and_then(toml::Value::as_table)
.context("missing package table")?;
if package.get("name").and_then(toml::Value::as_str) != Some(name)
|| package.get("version").and_then(toml::Value::as_str) != Some(version)
{
bail!("package name or version differs from the committed manifest");
}
let vcs = serde_json::to_vec(&serde_json::json!({"git": {"sha1": sha}, "path_in_vcs": ""}))?;
for (path, bytes) in [("Cargo.toml.orig", manifest), (".cargo_vcs_info.json", vcs)] {
let mut header = tar::Header::new_gnu();
header.set_size(bytes.len() as u64);
header.set_mode(0o644);
header.set_mtime(0);
header.set_cksum();
archive.append_data(&mut header, Path::new(&prefix).join(path), bytes.as_slice())?;
}
Ok(archive.into_inner()?.finish()?)
}
#[cfg(test)]
#[path = "package_archive_tests.rs"]
mod tests;