use std::fs;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
use zip::write::SimpleFileOptions;
use zip::{CompressionMethod, ZipArchive, ZipWriter};
use crate::error::{AxiomError, Result};
use crate::fs::LocalContextFs;
use crate::uri::AxiomUri;
pub fn export_ovpack(
fs: &LocalContextFs,
source: &AxiomUri,
destination: &Path,
) -> Result<PathBuf> {
let source_path = fs.resolve_uri(source);
if !source_path.exists() {
return Err(AxiomError::NotFound(source.to_string()));
}
let source_meta = fs::symlink_metadata(&source_path)?;
if source_meta.file_type().is_symlink() {
return Err(AxiomError::SecurityViolation(format!(
"ovpack export source must not be a symlink: {source}"
)));
}
if !source_meta.file_type().is_dir() {
return Err(AxiomError::Validation(
"ovpack export source must be a directory".to_string(),
));
}
let mut out_path = destination.to_path_buf();
if out_path.extension().is_none()
|| out_path.extension().and_then(|s| s.to_str()) != Some("ovpack")
{
out_path.set_extension("ovpack");
}
if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent)?;
}
let file = fs::File::create(&out_path)?;
let mut zip = ZipWriter::new(file);
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
let base_name = source
.last_segment()
.map_or_else(|| source.scope().as_str().to_string(), ToString::to_string);
let transformed_root = transform_component(&base_name);
zip.add_directory(format!("{transformed_root}/"), options)?;
for entry in WalkDir::new(&source_path).follow_links(false) {
let entry = entry.map_err(|e| AxiomError::Validation(e.to_string()))?;
if entry.path() == source_path {
continue;
}
if entry.file_type().is_symlink() {
continue;
}
let rel = entry
.path()
.strip_prefix(&source_path)
.map_err(|e| AxiomError::Validation(e.to_string()))?;
let transformed_rel = rel
.components()
.map(|c| transform_component(&c.as_os_str().to_string_lossy()))
.collect::<Vec<_>>()
.join("/");
let zip_path = format!("{transformed_root}/{transformed_rel}");
if entry.file_type().is_dir() {
zip.add_directory(format!("{zip_path}/"), options)?;
} else {
zip.start_file(zip_path, options)?;
let bytes = fs::read(entry.path())?;
zip.write_all(&bytes)?;
}
}
zip.finish()?;
Ok(out_path)
}
pub fn import_ovpack(
fs: &LocalContextFs,
file_path: &Path,
parent: &AxiomUri,
force: bool,
) -> Result<AxiomUri> {
if !file_path.exists() {
return Err(AxiomError::NotFound(file_path.display().to_string()));
}
let file = fs::File::open(file_path)?;
let mut archive = ZipArchive::new(file)?;
if archive.is_empty() {
return Err(AxiomError::InvalidArchive("empty archive".to_string()));
}
let root_component = {
let first = archive.by_index(0)?.name().to_string();
first
.split('/')
.find(|s| !s.is_empty())
.ok_or_else(|| AxiomError::InvalidArchive("archive has invalid root".to_string()))?
.to_string()
};
let base_name = reverse_component(&root_component);
let target_root = parent.join(&base_name)?;
if fs.exists(&target_root) {
if !force {
return Err(AxiomError::Conflict(format!(
"target exists: {target_root}"
)));
}
fs.rm(&target_root, true, true)?;
}
fs.create_dir_all(&target_root, true)?;
let target_root_path = fs.resolve_uri(&target_root);
for i in 0..archive.len() {
let mut entry = archive.by_index(i)?;
let name = entry.name().to_string();
if name.contains('\\') {
return Err(AxiomError::SecurityViolation(format!(
"backslash archive entry: {name}"
)));
}
if name.starts_with('/') || looks_like_windows_abs(&name) {
return Err(AxiomError::SecurityViolation(format!(
"absolute archive entry: {name}"
)));
}
let raw_parts = name
.split('/')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>();
if raw_parts.is_empty() {
continue;
}
if raw_parts[0] != root_component {
return Err(AxiomError::InvalidArchive(format!(
"archive root mismatch: expected {}, got {}",
root_component, raw_parts[0]
)));
}
let mut parts = Vec::new();
for raw in raw_parts {
let reversed = reverse_component(raw);
if reversed == "." || reversed == ".." {
return Err(AxiomError::SecurityViolation(format!(
"traversal archive entry: {name}"
)));
}
parts.push(reversed);
}
if parts.is_empty() {
continue;
}
let mut dest = target_root_path.clone();
for part in parts.iter().skip(1) {
if part.contains(std::path::MAIN_SEPARATOR) {
return Err(AxiomError::SecurityViolation(format!(
"invalid path separator in entry: {name}"
)));
}
dest.push(part);
}
if !dest.starts_with(&target_root_path) {
return Err(AxiomError::SecurityViolation(format!(
"zip-slip attempt detected: {name}"
)));
}
if entry.is_dir() || name.ends_with('/') {
fs::create_dir_all(&dest)?;
continue;
}
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)?;
}
let mut buf = Vec::new();
entry.read_to_end(&mut buf)?;
fs::write(dest, buf)?;
}
Ok(target_root)
}
fn looks_like_windows_abs(path: &str) -> bool {
let chars = path.chars().collect::<Vec<_>>();
chars.len() >= 2 && chars[1] == ':'
}
fn transform_component(component: &str) -> String {
component.strip_prefix('.').map_or_else(
|| component.to_string(),
|stripped| format!("_._{stripped}"),
)
}
fn reverse_component(component: &str) -> String {
component
.strip_prefix("_._")
.map_or_else(|| component.to_string(), |stripped| format!(".{stripped}"))
}
#[cfg(test)]
mod tests {
use std::io::Write;
use tempfile::tempdir;
use super::*;
use crate::uri::Scope;
#[cfg(unix)]
use std::os::unix::fs::symlink;
#[test]
fn ovpack_roundtrip_preserves_dotfiles() {
let temp = tempdir().expect("tempdir");
let fsys = LocalContextFs::new(temp.path());
fsys.initialize().expect("init failed");
let src = AxiomUri::root(Scope::Resources).join("demo").expect("join");
fsys.create_dir_all(&src, true).expect("mkdir");
fs::write(fsys.resolve_uri(&src).join(".abstract.md"), "hello").expect("write");
fs::write(fsys.resolve_uri(&src).join("note.txt"), "world").expect("write");
let pack_path = export_ovpack(&fsys, &src, &temp.path().join("demo")).expect("export");
let imported = import_ovpack(&fsys, &pack_path, &AxiomUri::root(Scope::Resources), true)
.expect("import");
let imported_path = fsys.resolve_uri(&imported);
assert!(imported_path.join(".abstract.md").exists());
assert_eq!(
fs::read_to_string(imported_path.join("note.txt")).expect("read"),
"world"
);
}
#[test]
fn ovpack_rejects_zip_slip() {
let temp = tempdir().expect("tempdir");
let fsys = LocalContextFs::new(temp.path());
fsys.initialize().expect("init failed");
let attack = temp.path().join("attack.ovpack");
let file = fs::File::create(&attack).expect("create");
let mut writer = ZipWriter::new(file);
let options = SimpleFileOptions::default();
writer
.start_file("root/../../pwned.txt", options)
.expect("start file");
writer.write_all(b"x").expect("write file");
writer.finish().expect("finish");
let err = import_ovpack(&fsys, &attack, &AxiomUri::root(Scope::Resources), false)
.expect_err("must fail");
assert!(matches!(err, AxiomError::SecurityViolation(_)));
}
#[test]
fn ovpack_rejects_mixed_archive_roots() {
let temp = tempdir().expect("tempdir");
let fsys = LocalContextFs::new(temp.path());
fsys.initialize().expect("init failed");
let attack = temp.path().join("mixed-roots.ovpack");
let file = fs::File::create(&attack).expect("create");
let mut writer = ZipWriter::new(file);
let options = SimpleFileOptions::default();
writer
.start_file("root/a.txt", options)
.expect("start file root");
writer.write_all(b"a").expect("write root file");
writer
.start_file("other/b.txt", options)
.expect("start file other");
writer.write_all(b"b").expect("write other file");
writer.finish().expect("finish");
let err = import_ovpack(&fsys, &attack, &AxiomUri::root(Scope::Resources), false)
.expect_err("must fail");
assert!(matches!(err, AxiomError::InvalidArchive(_)));
}
#[test]
fn ovpack_rejects_backslash_path_entries() {
let temp = tempdir().expect("tempdir");
let fsys = LocalContextFs::new(temp.path());
fsys.initialize().expect("init failed");
let attack = temp.path().join("backslash.ovpack");
let file = fs::File::create(&attack).expect("create");
let mut writer = ZipWriter::new(file);
let options = SimpleFileOptions::default();
writer
.start_file("root\\pwned.txt", options)
.expect("start file");
writer.write_all(b"x").expect("write file");
writer.finish().expect("finish");
let err = import_ovpack(&fsys, &attack, &AxiomUri::root(Scope::Resources), false)
.expect_err("must fail");
assert!(
matches!(
err,
AxiomError::SecurityViolation(_)
| AxiomError::InvalidArchive(_)
| AxiomError::InvalidUri(_)
),
"unexpected error: {err:?}"
);
}
#[cfg(unix)]
#[test]
fn ovpack_export_skips_symlink_entries() {
let temp = tempdir().expect("tempdir");
let outside = tempdir().expect("outside");
let fsys = LocalContextFs::new(temp.path());
fsys.initialize().expect("init failed");
let src = AxiomUri::root(Scope::Resources).join("demo").expect("join");
fsys.create_dir_all(&src, true).expect("mkdir");
fs::write(fsys.resolve_uri(&src).join("note.txt"), "world").expect("write note");
let outside_file = outside.path().join("secret.txt");
fs::write(&outside_file, "outside").expect("write outside");
symlink(&outside_file, fsys.resolve_uri(&src).join("linked.txt")).expect("symlink file");
let pack_path = export_ovpack(&fsys, &src, &temp.path().join("demo")).expect("export");
let imported = import_ovpack(&fsys, &pack_path, &AxiomUri::root(Scope::Resources), true)
.expect("import");
let imported_path = fsys.resolve_uri(&imported);
assert!(imported_path.join("note.txt").exists());
assert!(!imported_path.join("linked.txt").exists());
}
#[cfg(unix)]
#[test]
fn ovpack_export_rejects_symlink_source_root() {
let temp = tempdir().expect("tempdir");
let fsys = LocalContextFs::new(temp.path());
fsys.initialize().expect("init failed");
let real = AxiomUri::root(Scope::Resources).join("real").expect("real");
fsys.create_dir_all(&real, true).expect("mkdir real");
fs::write(fsys.resolve_uri(&real).join("note.txt"), "real").expect("write real");
let alias_path = temp.path().join("resources").join("alias");
symlink(fsys.resolve_uri(&real), &alias_path).expect("symlink dir");
let alias = AxiomUri::root(Scope::Resources)
.join("alias")
.expect("alias");
let err = export_ovpack(&fsys, &alias, &temp.path().join("alias"))
.expect_err("symlink source must be rejected");
assert!(matches!(err, AxiomError::SecurityViolation(_)));
}
}