use anyhow::{Context, Result};
use sha2::{Digest, Sha256};
use std::fs;
use std::io::{BufReader, Read};
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArchiveFormat {
Zip,
Squashfs,
}
pub struct ArchiveSpec {
pub name: String,
pub file: PathBuf,
}
impl ArchiveSpec {
pub fn parse(arg: &str) -> Result<Self> {
if let Some(colon) = arg.find(':') {
let whole = Path::new(arg);
if whole.is_file() {
let name = stem(arg);
validate_name(&name, arg)?;
return Ok(Self {
name,
file: whole
.canonicalize()
.with_context(|| format!("failed to resolve path {}", whole.display()))?,
});
}
let name = arg[..colon].to_string();
validate_name(&name, arg)?;
let file = Path::new(&arg[colon + 1..]);
if !file.is_file() {
anyhow::bail!("archive file not found: {}", file.display());
}
Ok(Self {
name,
file: file
.canonicalize()
.with_context(|| format!("failed to resolve path {}", file.display()))?,
})
} else {
let file = Path::new(arg);
if !file.is_file() {
anyhow::bail!("archive file not found: {}", file.display());
}
let name = stem(arg);
validate_name(&name, arg)?;
Ok(Self {
name,
file: file
.canonicalize()
.with_context(|| format!("failed to resolve path {}", file.display()))?,
})
}
}
}
fn validate_name(name: &str, source: &str) -> Result<()> {
if name.is_empty() {
anyhow::bail!(
"archive name derived from {:?} is empty; use NAME:FILE to provide an explicit name",
source
);
}
if name == "." || name == ".." {
anyhow::bail!(
"archive name {:?} (derived from {:?}) is not a valid directory name; \
use NAME:FILE to provide an explicit name",
name,
source
);
}
if name.contains('/') || name.contains('\0') {
anyhow::bail!(
"archive name {:?} (derived from {:?}) contains an invalid character; \
use NAME:FILE to provide an explicit name",
name,
source
);
}
Ok(())
}
fn stem(path: &str) -> String {
let base = Path::new(path)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string());
let base = base.strip_suffix(".sfs").unwrap_or(&base);
let base = base.strip_suffix(".zip").unwrap_or(base);
let base = base.strip_suffix(".b64").unwrap_or(base);
base.to_string()
}
pub fn try_decode_base64(src: &Path, dest: &Path) -> Result<bool> {
use std::io::{BufRead, BufReader};
let f = fs::File::open(src).with_context(|| format!("failed to open {}", src.display()))?;
let reader = BufReader::new(f);
let tmp_dest = dest.with_extension("tmp");
let mut dec: Option<crate::b64stream::B64Decoder<fs::File>> = None;
let result = (|| {
for line in reader.lines() {
let line = line.with_context(|| format!("failed to read {}", src.display()))?;
if line.starts_with('#') {
continue;
}
let trimmed = line.trim_end();
if trimmed.is_empty() {
continue;
}
if dec.is_none() {
let out = fs::File::create(&tmp_dest)
.with_context(|| format!("failed to create {}", tmp_dest.display()))?;
dec = Some(crate::b64stream::B64Decoder::new(out));
}
let accepted = dec
.as_mut()
.unwrap()
.push_line(trimmed)
.with_context(|| format!("base64 decode failed for {}", src.display()))?;
if !accepted {
return Ok(false);
}
}
let Some(dec) = dec else {
return Ok(false);
};
dec.finish()
.with_context(|| format!("base64 decode failed for {}", src.display()))?;
fs::rename(&tmp_dest, dest).with_context(|| {
format!(
"failed to rename {} to {}",
tmp_dest.display(),
dest.display()
)
})?;
Ok(true)
})();
if result.is_err() {
let _ = fs::remove_file(&tmp_dest);
}
result
}
pub fn detect_format(path: &Path) -> Result<ArchiveFormat> {
let mut f =
fs::File::open(path).with_context(|| format!("failed to open {}", path.display()))?;
let mut magic = [0u8; 4];
f.read_exact(&mut magic)
.with_context(|| format!("failed to read magic bytes from {}", path.display()))?;
match &magic {
b"PK\x03\x04" => Ok(ArchiveFormat::Zip),
b"hsqs" | b"sqsh" => Ok(ArchiveFormat::Squashfs),
_ => anyhow::bail!(
"{}: unrecognised archive format (magic {:02x?})",
path.display(),
magic
),
}
}
pub fn compute_sha256(path: &Path) -> Result<String> {
let mut f =
fs::File::open(path).with_context(|| format!("failed to open {}", path.display()))?;
let mut hasher = Sha256::new();
let mut buf = vec![0u8; 65536];
loop {
let n = f.read(&mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
let result = hasher.finalize();
Ok(hex::encode(&result[..8])) }
pub fn extract_zip(archive: &Path, dest: &Path) -> Result<()> {
let file =
fs::File::open(archive).with_context(|| format!("failed to open {}", archive.display()))?;
let mut zip = zip::ZipArchive::new(file)
.with_context(|| format!("not a valid zip archive: {}", archive.display()))?;
zip.extract(dest).with_context(|| {
format!(
"failed to extract {} into {}",
archive.display(),
dest.display()
)
})?;
Ok(())
}
pub fn extract_squashfs(sfs: &Path, dest: &Path) -> Result<()> {
use backhand::{FilesystemReader, InnerNode};
let file = BufReader::new(
fs::File::open(sfs).with_context(|| format!("failed to open {}", sfs.display()))?,
);
let filesystem = FilesystemReader::from_reader(file)
.with_context(|| format!("failed to read squashfs image {}", sfs.display()))?;
for node in filesystem.files() {
let rel = node.fullpath.strip_prefix("/").unwrap_or(&node.fullpath);
let out = dest.join(rel);
match &node.inner {
InnerNode::Dir(_) if out != dest => {
fs::create_dir_all(&out)
.with_context(|| format!("failed to create dir {}", out.display()))?;
}
InnerNode::Dir(_) => {}
InnerNode::File(file_reader) => {
if let Some(parent) = out.parent() {
fs::create_dir_all(parent)?;
}
let mut out_file = fs::File::create(&out)
.with_context(|| format!("failed to create {}", out.display()))?;
let mut reader = filesystem.file(file_reader).reader();
std::io::copy(&mut reader, &mut out_file)
.with_context(|| format!("failed to write {}", out.display()))?;
fs::set_permissions(
&out,
fs::Permissions::from_mode(node.header.permissions as u32),
)?;
}
InnerNode::Symlink(sym) => {
if let Some(parent) = out.parent() {
fs::create_dir_all(parent)?;
}
std::os::unix::fs::symlink(&sym.link, &out)
.with_context(|| format!("failed to create symlink {}", out.display()))?;
}
_ => {}
}
}
Ok(())
}
pub fn zip_to_squashfs(zip: &Path, sfs_dest: &Path, tmp_dir: &Path) -> Result<bool> {
if std::process::Command::new("mksquashfs")
.arg("-version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_err()
{
return Ok(false);
}
extract_zip(zip, tmp_dir)?;
let status = std::process::Command::new("mksquashfs")
.args([
tmp_dir.as_os_str(),
sfs_dest.as_os_str(),
"-comp".as_ref(),
"zstd".as_ref(),
"-Xcompression-level".as_ref(),
"1".as_ref(),
"-noappend".as_ref(),
"-quiet".as_ref(),
])
.status()
.context("failed to run mksquashfs")?;
if !status.success() {
anyhow::bail!("mksquashfs exited with status {:?}", status.code());
}
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn stem_no_extension() {
assert_eq!(stem("archive"), "archive");
}
#[test]
fn stem_zip() {
assert_eq!(stem("archive.zip"), "archive");
}
#[test]
fn stem_sfs() {
assert_eq!(stem("archive.sfs"), "archive");
}
#[test]
fn stem_b64() {
assert_eq!(stem("archive.b64"), "archive");
}
#[test]
fn stem_with_directory() {
assert_eq!(stem("/some/path/archive.zip"), "archive");
}
#[test]
fn stem_double_known_extension_zip_b64() {
assert_eq!(stem("archive.zip.b64"), "archive.zip");
}
#[test]
fn stem_double_known_extension_sfs_zip() {
assert_eq!(stem("archive.sfs.zip"), "archive.sfs");
}
#[test]
fn stem_extension_only() {
assert_eq!(stem(".zip"), "");
}
#[test]
fn stem_unknown_extension() {
assert_eq!(stem("archive.tar.gz"), "archive.tar.gz");
}
fn write_tmp(bytes: &[u8]) -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(bytes).unwrap();
f
}
#[test]
fn detect_format_zip() {
let f = write_tmp(b"PK\x03\x04extra bytes");
assert_eq!(detect_format(f.path()).unwrap(), ArchiveFormat::Zip);
}
#[test]
fn detect_format_squashfs_little_endian() {
let f = write_tmp(b"hsqsextra");
assert_eq!(detect_format(f.path()).unwrap(), ArchiveFormat::Squashfs);
}
#[test]
fn detect_format_squashfs_big_endian() {
let f = write_tmp(b"sqshextra");
assert_eq!(detect_format(f.path()).unwrap(), ArchiveFormat::Squashfs);
}
#[test]
fn detect_format_unknown_magic() {
let f = write_tmp(b"\x00\x01\x02\x03");
assert!(detect_format(f.path()).is_err());
}
#[test]
fn detect_format_too_small() {
let f = write_tmp(b"PK\x03"); assert!(detect_format(f.path()).is_err());
}
#[test]
fn detect_format_nonexistent() {
assert!(detect_format(std::path::Path::new("/nonexistent/path.zip")).is_err());
}
#[test]
fn compute_sha256_empty_file() {
let f = write_tmp(b"");
assert_eq!(compute_sha256(f.path()).unwrap(), "e3b0c44298fc1c14");
}
#[test]
fn compute_sha256_known_content() {
let f = write_tmp(b"hello");
assert_eq!(compute_sha256(f.path()).unwrap(), "2cf24dba5fb0a30e");
}
#[test]
fn compute_sha256_returns_16_chars() {
let f = write_tmp(b"any content");
assert_eq!(compute_sha256(f.path()).unwrap().len(), 16);
}
#[test]
fn compute_sha256_nonexistent() {
assert!(compute_sha256(std::path::Path::new("/nonexistent")).is_err());
}
#[test]
fn decode_base64_pure_content() {
let src = write_tmp(b"aGVsbG8=");
let dest = tempfile::NamedTempFile::new().unwrap();
let ok = try_decode_base64(src.path(), dest.path()).unwrap();
assert!(ok);
assert_eq!(fs::read(dest.path()).unwrap(), b"hello");
}
#[test]
fn decode_base64_with_hash_comments() {
let src = write_tmp(b"# This is a comment\n## Another comment\naGVsbG8=\n");
let dest = tempfile::NamedTempFile::new().unwrap();
let ok = try_decode_base64(src.path(), dest.path()).unwrap();
assert!(ok);
assert_eq!(fs::read(dest.path()).unwrap(), b"hello");
}
#[test]
fn decode_base64_multiline_data() {
let src = write_tmp(b"# comment\naGVs\nbG8=\n");
let dest = tempfile::NamedTempFile::new().unwrap();
let ok = try_decode_base64(src.path(), dest.path()).unwrap();
assert!(ok);
assert_eq!(fs::read(dest.path()).unwrap(), b"hello");
}
#[test]
fn decode_base64_binary_content_is_not_base64() {
let src = write_tmp(b"PK\x03\x04");
let dest = tempfile::NamedTempFile::new().unwrap();
let ok = try_decode_base64(src.path(), dest.path()).unwrap();
assert!(!ok);
}
#[test]
fn decode_base64_only_comments_is_not_base64() {
let src = write_tmp(b"# no data here\n# nothing\n");
let dest = tempfile::NamedTempFile::new().unwrap();
let ok = try_decode_base64(src.path(), dest.path()).unwrap();
assert!(!ok);
}
#[test]
fn validate_name_accepts_normal_name() {
assert!(validate_name("mydata", "mydata.zip").is_ok());
}
#[test]
fn validate_name_rejects_empty() {
assert!(validate_name("", ".zip").is_err());
}
#[test]
fn validate_name_rejects_dot() {
assert!(validate_name(".", "./file.zip").is_err());
}
#[test]
fn validate_name_rejects_dotdot() {
assert!(validate_name("..", "../file.zip").is_err());
}
#[test]
fn validate_name_rejects_slash() {
assert!(validate_name("foo/bar", "foo/bar.zip").is_err());
}
#[test]
fn validate_name_rejects_null_byte() {
assert!(validate_name("foo\0bar", "foo\0bar.zip").is_err());
}
}