use std::fs::{self, File};
use std::io::{self, Read, Write};
use std::path::Path;
use zip::write::SimpleFileOptions;
use zip::CompressionMethod;
use zip::ZipWriter;
pub(crate) fn create_nar(release_dir: &Path, nar_path: &Path) -> io::Result<u64> {
if let Some(parent) = nar_path.parent()
&& !parent.exists()
{
fs::create_dir_all(parent)?;
}
let file = File::create(nar_path)?;
let mut zip = ZipWriter::new(file);
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
let mut buffer = Vec::new();
add_dir_to_zip(&mut zip, release_dir, release_dir, &options, &mut buffer)?;
zip.finish().map_err(io::Error::other)?;
let metadata = fs::metadata(nar_path)?;
Ok(metadata.len())
}
fn add_dir_to_zip<W: Write + io::Seek>(
zip: &mut ZipWriter<W>,
root: &Path,
current: &Path,
options: &SimpleFileOptions,
buffer: &mut Vec<u8>,
) -> io::Result<()> {
for entry in fs::read_dir(current)? {
let entry = entry?;
let file_type = entry.file_type()?;
if file_type.is_symlink() {
continue;
}
let path = entry.path();
if file_type.is_dir() {
if entry.file_name() == "profile" {
continue;
}
add_dir_to_zip(zip, root, &path, options, buffer)?;
} else if file_type.is_file() {
let relative = path
.strip_prefix(root)
.map_err(|e| io::Error::other(e.to_string()))?
.to_string_lossy()
.replace('\\', "/");
debug_assert!(
!relative.split('/').any(|c| c == ".."),
"path traversal in ZIP entry: {relative}"
);
zip.start_file(&relative, *options)
.map_err(io::Error::other)?;
buffer.clear();
File::open(&path)?.read_to_end(buffer)?;
zip.write_all(buffer)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_create_nar_basic() {
let temp = TempDir::new().unwrap();
let release = temp.path().join("release");
fs::create_dir_all(release.join("ghost/master")).unwrap();
fs::write(release.join("ghost/master/descript.txt"), "test").unwrap();
fs::write(release.join("install.txt"), "install").unwrap();
let nar_path = temp.path().join("out.nar");
let size = create_nar(&release, &nar_path).unwrap();
assert!(size > 0);
assert!(nar_path.exists());
let file = File::open(&nar_path).unwrap();
let mut archive = zip::ZipArchive::new(file).unwrap();
let names: Vec<String> = (0..archive.len())
.map(|i| archive.by_index(i).unwrap().name().to_string())
.collect();
assert!(names.contains(&"ghost/master/descript.txt".to_string()));
assert!(names.contains(&"install.txt".to_string()));
}
#[test]
fn test_create_nar_excludes_profile() {
let temp = TempDir::new().unwrap();
let release = temp.path().join("release");
fs::create_dir_all(release.join("profile")).unwrap();
fs::write(release.join("profile/user.txt"), "data").unwrap();
fs::write(release.join("install.txt"), "install").unwrap();
let nar_path = temp.path().join("out.nar");
create_nar(&release, &nar_path).unwrap();
let file = File::open(&nar_path).unwrap();
let mut archive = zip::ZipArchive::new(file).unwrap();
let names: Vec<String> = (0..archive.len())
.map(|i| archive.by_index(i).unwrap().name().to_string())
.collect();
assert!(!names.iter().any(|n| n.contains("profile")));
assert!(names.contains(&"install.txt".to_string()));
}
#[test]
fn test_create_nar_excludes_self_deploy_dir() {
let temp = TempDir::new().unwrap();
let release = temp.path().join("release");
let ghost_master = release.join("ghost/master");
fs::create_dir_all(&ghost_master).unwrap();
fs::write(ghost_master.join("descript.txt"), "desc").unwrap();
fs::create_dir_all(ghost_master.join("dic")).unwrap();
fs::write(ghost_master.join("dic/foo.pasta"), "foo").unwrap();
let self_deploy = ghost_master.join("profile/pasta/pasta_scripts");
fs::create_dir_all(&self_deploy).unwrap();
fs::write(self_deploy.join("main.lua"), "-- framework script").unwrap();
fs::write(self_deploy.join(".md5"), "deadbeef").unwrap();
let nar_path = temp.path().join("out.nar");
create_nar(&release, &nar_path).unwrap();
let file = File::open(&nar_path).unwrap();
let mut archive = zip::ZipArchive::new(file).unwrap();
let names: Vec<String> = (0..archive.len())
.map(|i| archive.by_index(i).unwrap().name().to_string())
.collect();
assert!(
names.contains(&"ghost/master/descript.txt".to_string()),
"descript.txt must be archived: {names:?}"
);
assert!(
names.contains(&"ghost/master/dic/foo.pasta".to_string()),
"dic/foo.pasta must be archived: {names:?}"
);
assert!(
!names.iter().any(|n| n.contains("profile")),
"no profile/ entry must be archived: {names:?}"
);
assert!(
!names.iter().any(|n| n.contains("main.lua")),
"self-deploy script must not be archived: {names:?}"
);
assert!(
!names.iter().any(|n| n.ends_with(".md5")),
".md5 marker must not be archived: {names:?}"
);
}
#[test]
fn test_create_nar_creates_parent_dir() {
let temp = TempDir::new().unwrap();
let release = temp.path().join("release");
fs::create_dir_all(&release).unwrap();
fs::write(release.join("a.txt"), "a").unwrap();
let nar_path = temp.path().join("nested/dir/out.nar");
let size = create_nar(&release, &nar_path).unwrap();
assert!(size > 0);
assert!(nar_path.exists());
}
#[cfg(unix)]
#[test]
fn test_create_nar_excludes_symlinks() {
use std::os::unix::fs as unix_fs;
let temp = TempDir::new().unwrap();
let release = temp.path().join("release");
fs::create_dir_all(release.join("ghost")).unwrap();
fs::write(release.join("ghost/real.txt"), "real").unwrap();
unix_fs::symlink(
release.join("ghost/real.txt"),
release.join("ghost/link.txt"),
)
.unwrap();
unix_fs::symlink(release.join("ghost"), release.join("linked_dir")).unwrap();
let nar_path = temp.path().join("out.nar");
create_nar(&release, &nar_path).unwrap();
let file = File::open(&nar_path).unwrap();
let mut archive = zip::ZipArchive::new(file).unwrap();
let names: Vec<String> = (0..archive.len())
.map(|i| archive.by_index(i).unwrap().name().to_string())
.collect();
assert!(names.contains(&"ghost/real.txt".to_string()));
assert!(!names.iter().any(|n| n.contains("link.txt")));
assert!(!names.iter().any(|n| n.contains("linked_dir")));
}
#[test]
fn test_create_nar_overwrites_existing() {
let temp = TempDir::new().unwrap();
let release = temp.path().join("release");
fs::create_dir_all(&release).unwrap();
fs::write(release.join("a.txt"), "a").unwrap();
let nar_path = temp.path().join("out.nar");
fs::write(&nar_path, "old content").unwrap();
let size = create_nar(&release, &nar_path).unwrap();
assert!(size > 0);
let file = File::open(&nar_path).unwrap();
let archive = zip::ZipArchive::new(file).unwrap();
assert_eq!(archive.len(), 1);
}
}