use std::io::Cursor;
use std::path::{Path, PathBuf};
use flate2::Compression;
use flate2::GzBuilder;
use crate::error::{Result, RototoError};
use crate::lint::lint_package;
use crate::source::{SourceOptions, stage_package_source};
const PACKAGE_MANIFEST: &str = "rototo-package.toml";
#[derive(Debug, Clone)]
pub struct PackagedArchive {
pub release_id: String,
pub file_name: String,
pub bytes: Vec<u8>,
}
pub async fn pack_package(source: &str, options: &SourceOptions) -> Result<PackagedArchive> {
let staged = stage_package_source(source, options).await?;
let lint = lint_package(staged.path()).await?;
if lint.has_errors() {
let errors = lint
.diagnostics
.iter()
.filter(|diagnostic| diagnostic.severity == crate::diagnostics::Severity::Error)
.count();
return Err(RototoError::new(format!(
"cannot package `{source}`: {errors} lint error(s); run `rototo lint {source}` for details"
)));
}
let root = staged.path().to_path_buf();
let bytes = tokio::task::spawn_blocking(move || build_archive(&root))
.await
.map_err(|err| RototoError::new(format!("package archive task failed: {err}")))??;
let release_id = format!("sha256:{}", sha256_hex(&bytes));
let file_name = format!("{release_id}.tar.gz");
Ok(PackagedArchive {
release_id,
file_name,
bytes,
})
}
fn build_archive(root: &Path) -> Result<Vec<u8>> {
let mut files = Vec::new();
collect_files(root, root, &mut files)?;
files.sort_by(|(left, _), (right, _)| left.cmp(right));
let encoder = GzBuilder::new()
.mtime(0)
.write(Vec::new(), Compression::new(6));
let mut builder = tar::Builder::new(encoder);
for (archive_path, absolute) in &files {
let contents = if archive_path == PACKAGE_MANIFEST {
manifest_bytes(absolute)?
} else {
std::fs::read(absolute).map_err(|err| {
RototoError::new(format!(
"failed to read package file {}: {err}",
absolute.display()
))
})?
};
append_file(&mut builder, archive_path, &contents)?;
}
let encoder = builder
.into_inner()
.map_err(|err| RototoError::new(format!("failed to finish package archive: {err}")))?;
encoder
.finish()
.map_err(|err| RototoError::new(format!("failed to compress package archive: {err}")))
}
fn collect_files(root: &Path, dir: &Path, files: &mut Vec<(String, PathBuf)>) -> Result<()> {
let entries = std::fs::read_dir(dir).map_err(|err| {
RototoError::new(format!(
"failed to read package directory {}: {err}",
dir.display()
))
})?;
for entry in entries {
let entry = entry
.map_err(|err| RototoError::new(format!("failed to read package entry: {err}")))?;
let file_name = entry.file_name();
if file_name == ".git" {
continue;
}
let file_type = entry.file_type().map_err(|err| {
RototoError::new(format!(
"failed to inspect package entry {}: {err}",
entry.path().display()
))
})?;
if file_type.is_symlink() {
continue;
}
let path = entry.path();
if file_type.is_dir() {
collect_files(root, &path, files)?;
} else if file_type.is_file() {
let relative = path.strip_prefix(root).map_err(|_| {
RototoError::new(format!(
"package file {} is outside the package root",
path.display()
))
})?;
files.push((archive_path(relative), path));
}
}
Ok(())
}
fn archive_path(relative: &Path) -> String {
relative
.to_string_lossy()
.replace(std::path::MAIN_SEPARATOR, "/")
}
fn manifest_bytes(path: &Path) -> Result<Vec<u8>> {
let raw = std::fs::read(path).map_err(|err| {
RototoError::new(format!(
"failed to read package manifest {}: {err}",
path.display()
))
})?;
let text = std::str::from_utf8(&raw)
.map_err(|err| RototoError::new(format!("package manifest is not valid UTF-8: {err}")))?;
let mut manifest = text
.parse::<toml::Value>()
.map_err(|err| RototoError::new(format!("failed to parse package manifest: {err}")))?;
if let Some(table) = manifest.as_table_mut()
&& table.remove("extends").is_some()
{
return toml::to_string(&manifest)
.map(String::into_bytes)
.map_err(|err| RototoError::new(format!("failed to rewrite package manifest: {err}")));
}
Ok(raw)
}
fn append_file(
builder: &mut tar::Builder<impl std::io::Write>,
archive_path: &str,
contents: &[u8],
) -> Result<()> {
let mut header = tar::Header::new_gnu();
header.set_entry_type(tar::EntryType::Regular);
header.set_size(contents.len() as u64);
header.set_mode(0o644);
header.set_mtime(0);
header.set_uid(0);
header.set_gid(0);
header.set_cksum();
builder
.append_data(&mut header, archive_path, Cursor::new(contents))
.map_err(|err| {
RototoError::new(format!(
"failed to add {archive_path} to package archive: {err}"
))
})
}
fn sha256_hex(bytes: &[u8]) -> String {
let digest = ring::digest::digest(&ring::digest::SHA256, bytes);
let mut encoded = String::with_capacity(digest.as_ref().len() * 2);
for byte in digest.as_ref() {
use std::fmt::Write;
let _ = write!(encoded, "{byte:02x}");
}
encoded
}
#[cfg(test)]
mod tests {
use super::*;
async fn write_package(root: &Path) {
tokio::fs::write(root.join(PACKAGE_MANIFEST), "schema_version = 1\n")
.await
.unwrap();
tokio::fs::create_dir_all(root.join("variables"))
.await
.unwrap();
tokio::fs::write(
root.join("variables/flag.toml"),
"schema_version = 1\ntype = \"bool\"\n\n[resolve]\ndefault = true\n",
)
.await
.unwrap();
}
#[tokio::test]
async fn pack_package_is_deterministic_and_content_addressed() {
let temp = tempfile::TempDir::new().unwrap();
let root = temp.path().join("package");
tokio::fs::create_dir_all(&root).await.unwrap();
write_package(&root).await;
let source = root.display().to_string();
let first = pack_package(&source, &SourceOptions::default())
.await
.unwrap();
let second = pack_package(&source, &SourceOptions::default())
.await
.unwrap();
assert_eq!(first.bytes, second.bytes);
assert_eq!(first.release_id, second.release_id);
assert!(first.release_id.starts_with("sha256:"));
assert_eq!(first.file_name, format!("{}.tar.gz", first.release_id));
assert_eq!(
first.release_id,
format!("sha256:{}", sha256_hex(&first.bytes))
);
}
#[tokio::test]
async fn pack_package_strips_extends_from_the_manifest() {
let temp = tempfile::TempDir::new().unwrap();
let parent = temp.path().join("parent");
let child = temp.path().join("child");
tokio::fs::create_dir_all(&parent).await.unwrap();
tokio::fs::create_dir_all(&child).await.unwrap();
write_package(&parent).await;
tokio::fs::write(
child.join(PACKAGE_MANIFEST),
"schema_version = 1\nextends = [\"../parent\"]\n",
)
.await
.unwrap();
let archive = pack_package(&child.display().to_string(), &SourceOptions::default())
.await
.unwrap();
let manifest = read_archive_entry(&archive.bytes, PACKAGE_MANIFEST);
let manifest = String::from_utf8(manifest).unwrap();
assert!(!manifest.contains("extends"), "{manifest}");
assert!(!read_archive_entry(&archive.bytes, "variables/flag.toml").is_empty());
}
#[tokio::test]
async fn pack_package_rejects_lint_failures() {
let temp = tempfile::TempDir::new().unwrap();
let root = temp.path().join("package");
tokio::fs::create_dir_all(&root).await.unwrap();
tokio::fs::write(root.join(PACKAGE_MANIFEST), "name = \"broken\"\n")
.await
.unwrap();
let err = pack_package(&root.display().to_string(), &SourceOptions::default())
.await
.unwrap_err();
assert!(err.to_string().contains("lint error"), "{err}");
}
fn read_archive_entry(bytes: &[u8], wanted: &str) -> Vec<u8> {
use std::io::Read;
let decoder = flate2::read::GzDecoder::new(Cursor::new(bytes));
let mut archive = tar::Archive::new(decoder);
for entry in archive.entries().unwrap() {
let mut entry = entry.unwrap();
let path = entry.path().unwrap().to_string_lossy().into_owned();
if path == wanted {
let mut contents = Vec::new();
entry.read_to_end(&mut contents).unwrap();
return contents;
}
}
panic!("archive entry not found: {wanted}");
}
}