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?;
require_lint_clean(source, staged.path()).await?;
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,
})
}
pub async fn project_package(
source: &str,
options: &SourceOptions,
target: &Path,
) -> Result<Vec<String>> {
let staged = stage_package_source(source, options).await?;
require_lint_clean(source, staged.path()).await?;
let root = staged.path().to_path_buf();
let target = target.to_path_buf();
tokio::task::spawn_blocking(move || write_projection(&root, &target))
.await
.map_err(|err| RototoError::new(format!("package projection task failed: {err}")))?
}
async fn require_lint_clean(source: &str, staged_root: &Path) -> Result<()> {
let lint = lint_package(staged_root).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"
)));
}
Ok(())
}
fn write_projection(root: &Path, target: &Path) -> Result<Vec<String>> {
if target.exists() {
let mut entries = std::fs::read_dir(target).map_err(|err| {
RototoError::new(format!(
"failed to read target directory {}: {err}",
target.display()
))
})?;
if entries.next().is_some() {
return Err(RototoError::new(format!(
"target directory {} is not empty; refusing to write the package projection over existing files",
target.display()
)));
}
}
let mut files = Vec::new();
collect_files(root, root, &mut files)?;
files.sort_by(|(left, _), (right, _)| left.cmp(right));
let mut written = Vec::with_capacity(files.len());
for (relative, absolute) in &files {
let destination = target.join(Path::new(relative));
if let Some(parent) = destination.parent() {
std::fs::create_dir_all(parent).map_err(|err| {
RototoError::new(format!(
"failed to create directory {}: {err}",
parent.display()
))
})?;
}
let contents = if relative == PACKAGE_MANIFEST {
manifest_bytes(absolute)?
} else {
std::fs::read(absolute).map_err(|err| {
RototoError::new(format!(
"failed to read package file {}: {err}",
absolute.display()
))
})?
};
std::fs::write(&destination, contents).map_err(|err| {
RototoError::new(format!(
"failed to write package file {}: {err}",
destination.display()
))
})?;
written.push(relative.clone());
}
Ok(written)
}
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;
}
if file_name == "lint" && dir == root {
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 project_package_writes_the_flattened_tree() {
let temp = tempfile::TempDir::new().unwrap();
let parent = temp.path().join("parent");
let child = temp.path().join("child");
let target = temp.path().join("out");
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 written = project_package(
&child.display().to_string(),
&SourceOptions::default(),
&target,
)
.await
.unwrap();
assert!(written.contains(&"variables/flag.toml".to_string()));
let manifest = tokio::fs::read_to_string(target.join(PACKAGE_MANIFEST))
.await
.unwrap();
assert!(!manifest.contains("extends"), "{manifest}");
assert!(target.join("variables/flag.toml").exists());
}
#[tokio::test]
async fn project_package_refuses_a_non_empty_target() {
let temp = tempfile::TempDir::new().unwrap();
let root = temp.path().join("package");
let target = temp.path().join("out");
tokio::fs::create_dir_all(&root).await.unwrap();
tokio::fs::create_dir_all(&target).await.unwrap();
tokio::fs::write(target.join("stale.txt"), "leftover")
.await
.unwrap();
write_package(&root).await;
let err = project_package(
&root.display().to_string(),
&SourceOptions::default(),
&target,
)
.await
.unwrap_err();
assert!(err.to_string().contains("is not empty"), "{err}");
assert!(!target.join(PACKAGE_MANIFEST).exists());
}
async fn write_custom_lint(root: &Path) {
tokio::fs::create_dir_all(root.join("lint")).await.unwrap();
tokio::fs::write(
root.join("lint/budget.lua"),
"function register(lint)\n lint:rule({\n id = \"fixture/allow-all\",\n title = \"Allow all\",\n help = \"Never fires.\",\n target = \"variable=\",\n handler = \"check\",\n })\nend\n\nfunction check(target)\n return {}\nend\n",
)
.await
.unwrap();
}
#[tokio::test]
async fn pack_package_leaves_custom_lint_out_of_the_archive() {
let temp = tempfile::TempDir::new().unwrap();
let root = temp.path().join("package");
tokio::fs::create_dir_all(&root).await.unwrap();
write_package(&root).await;
write_custom_lint(&root).await;
let archive = pack_package(&root.display().to_string(), &SourceOptions::default())
.await
.unwrap();
let paths = archive_entry_paths(&archive.bytes);
assert!(
paths.iter().all(|path| !path.starts_with("lint/")),
"archive carries custom lint: {paths:?}"
);
assert!(
paths.contains(&"variables/flag.toml".to_owned()),
"{paths:?}"
);
}
#[tokio::test]
async fn project_package_leaves_custom_lint_out_of_the_projection() {
let temp = tempfile::TempDir::new().unwrap();
let root = temp.path().join("package");
let target = temp.path().join("out");
tokio::fs::create_dir_all(&root).await.unwrap();
write_package(&root).await;
write_custom_lint(&root).await;
let written = project_package(
&root.display().to_string(),
&SourceOptions::default(),
&target,
)
.await
.unwrap();
assert!(
written.iter().all(|path| !path.starts_with("lint/")),
"projection carries custom lint: {written:?}"
);
assert!(!target.join("lint").exists());
assert!(target.join("variables/flag.toml").exists());
}
#[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 archive_entry_paths(bytes: &[u8]) -> Vec<String> {
let decoder = flate2::read::GzDecoder::new(Cursor::new(bytes));
let mut archive = tar::Archive::new(decoder);
archive
.entries()
.unwrap()
.map(|entry| {
entry
.unwrap()
.path()
.unwrap()
.to_string_lossy()
.into_owned()
})
.collect()
}
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}");
}
}