use crate::container::{ContainerIndex, EntryKind};
use crate::error::{Error, Result};
use crate::restore::RestoreReport;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct SkippedEntry {
pub path: String,
pub reason: &'static str,
}
#[derive(Debug)]
pub struct ExtractReport {
pub root: PathBuf,
pub files: usize,
pub dirs: usize,
pub links: usize,
pub adjusted: Vec<String>,
pub skipped: Vec<SkippedEntry>,
pub metadata: RestoreReport,
}
pub fn extract_container(
idx: &ContainerIndex,
payload: &[u8],
target: &Path,
) -> Result<ExtractReport> {
extract_container_with(
idx,
&mut |off, len| {
let start = usize::try_from(off).unwrap_or(usize::MAX);
let end = start.saturating_add(usize::try_from(len).unwrap_or(usize::MAX));
payload.get(start..end).map(<[u8]>::to_vec).ok_or(Error::MalformedHeader {
reason: "container entry payload range out of bounds",
})
},
target,
)
}
pub const STREAM_WINDOW: u64 = 4 * 1024 * 1024;
pub fn extract_container_with(
idx: &ContainerIndex,
read: &mut dyn FnMut(u64, u64) -> Result<Vec<u8>>,
target: &Path,
) -> Result<ExtractReport> {
let root = target.join(sanitize_filename(&idx.root));
std::fs::create_dir_all(&root).map_err(Error::Io)?;
let mut files = 0usize;
let mut dirs = 0usize;
let mut links = 0usize;
let mut unsupported_links = 0usize;
let mut link_failures: Vec<(String, String)> = Vec::new();
let mut adjusted: Vec<String> = Vec::new();
let mut skipped: Vec<SkippedEntry> = Vec::new();
for e in &idx.entries {
if e.kind == EntryKind::Dir {
let (p, was_adjusted) = safe_join(&root, &e.path)?;
if was_adjusted {
adjusted.push(e.display_path());
}
std::fs::create_dir_all(&p).map_err(Error::Io)?;
dirs = dirs.saturating_add(1);
}
}
for e in &idx.entries {
match e.kind {
EntryKind::File => {
let (p, was_adjusted) = safe_join(&root, &e.path)?;
if was_adjusted {
adjusted.push(e.display_path());
}
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent).ok();
}
write_entry(&p, e.offset, e.size, e.hash.as_ref(), read)?;
files = files.saturating_add(1);
}
EntryKind::Symlink | EntryKind::Dir => {}
}
}
for e in &idx.entries {
if e.kind != EntryKind::Symlink {
continue;
}
let (p, was_adjusted) = safe_join(&root, &e.path)?;
if was_adjusted {
adjusted.push(e.display_path());
}
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent).ok();
}
let Some(target) = &e.target else {
skipped.push(SkippedEntry {
path: e.display_path(),
reason: "symlink_no_target",
});
continue;
};
if !link_target_stays_inside(&root, &p, target) {
skipped.push(SkippedEntry {
path: e.display_path(),
reason: "symlink_escapes_root",
});
continue;
}
match create_symlink(target, &p) {
Ok(()) => links = links.saturating_add(1),
Err(err) if is_privilege_error(&err) => {
unsupported_links = unsupported_links.saturating_add(1);
}
Err(err) => {
link_failures.push((e.display_path(), err.to_string()));
}
}
}
let mut metadata = crate::restore::restore_metadata(&root, idx);
metadata.note_unsupported_n(
crate::restore::UnsupportedKind::Symlink,
unsupported_links,
);
for (path, reason) in link_failures {
metadata.push_failure(path, "symlink", reason);
}
Ok(ExtractReport {
root,
files,
dirs,
links,
adjusted,
skipped,
metadata,
})
}
fn write_entry(
path: &Path,
offset: u64,
size: u64,
expect: Option<&[u8; 32]>,
read: &mut dyn FnMut(u64, u64) -> Result<Vec<u8>>,
) -> Result<()> {
use blake2::digest::{Digest, consts::U32};
use std::io::Write as _;
let mut out = std::io::BufWriter::new(std::fs::File::create(path).map_err(Error::Io)?);
let mut hasher = blake2::Blake2b::<U32>::new();
let mut done = 0u64;
let result = (|| {
while done < size {
let n = size.saturating_sub(done).min(STREAM_WINDOW);
let bytes = read(offset.saturating_add(done), n)?;
if bytes.len() as u64 != n {
return Err(Error::MalformedHeader {
reason: "container entry payload range out of bounds",
});
}
hasher.update(&bytes);
out.write_all(&bytes).map_err(Error::Io)?;
done = done.saturating_add(n);
}
out.flush().map_err(Error::Io)?;
if let Some(expect) = expect {
let actual: [u8; 32] = hasher.clone().finalize().into();
if &actual != expect {
return Err(Error::ContentHashMismatch);
}
}
Ok(())
})();
if result.is_err() {
drop(out);
let _ = std::fs::remove_file(path);
}
result
}
#[cfg(windows)]
fn create_symlink(target: &str, link: &Path) -> std::io::Result<()> {
let target = target.replace('/', "\\");
let base = link.parent().unwrap_or(Path::new("."));
let resolved = base.join(&target);
if resolved.is_dir() {
std::os::windows::fs::symlink_dir(&target, link)
} else {
std::os::windows::fs::symlink_file(&target, link)
}
}
#[cfg(unix)]
fn create_symlink(target: &str, link: &Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(target, link)
}
fn link_target_stays_inside(root: &Path, link: &Path, target: &str) -> bool {
let t = Path::new(target);
if t.is_absolute() || t.has_root() {
return false;
}
let Some(base) = link.parent() else {
return false;
};
let mut acc = base.to_path_buf();
for comp in t.components() {
match comp {
std::path::Component::ParentDir => {
if acc == root {
return false;
}
if !acc.pop() {
return false;
}
}
std::path::Component::CurDir => {}
other => acc.push(other.as_os_str()),
}
}
acc.starts_with(root)
}
#[cfg(windows)]
fn is_privilege_error(e: &std::io::Error) -> bool {
const ERROR_PRIVILEGE_NOT_HELD: i32 = 1314;
e.raw_os_error() == Some(ERROR_PRIVILEGE_NOT_HELD)
|| e.kind() == std::io::ErrorKind::PermissionDenied
}
#[cfg(not(windows))]
fn is_privilege_error(e: &std::io::Error) -> bool {
e.kind() == std::io::ErrorKind::PermissionDenied
}
pub fn safe_join(root: &Path, comps: &[String]) -> Result<(PathBuf, bool)> {
let mut p = root.to_path_buf();
let mut adjusted = false;
for c in comps {
crate::container::validate_component(c)?;
let s = sanitize_filename(c);
if s != *c {
adjusted = true;
}
p.push(s);
}
if !p.starts_with(root) {
return Err(Error::MalformedHeader {
reason: "container entry path escapes target directory",
});
}
Ok((p, adjusted))
}
#[must_use]
pub fn sanitize_filename(name: &str) -> String {
#[cfg(windows)]
{
const BAD: &[char] = &['<', '>', ':', '"', '/', '\\', '|', '?', '*'];
const RESERVED: &[&str] = &[
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7",
"COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8",
"LPT9",
];
let mut s: String = name
.chars()
.map(|c| {
if BAD.contains(&c) || (c as u32) < 0x20 {
'_'
} else {
c
}
})
.collect();
while s.ends_with('.') || s.ends_with(' ') {
s.pop();
}
let stem = s.split('.').next().unwrap_or("").to_ascii_uppercase();
if RESERVED.contains(&stem.as_str()) {
s = format!("_{s}");
}
if s.is_empty() {
s = String::from("_");
}
s
}
#[cfg(not(windows))]
{
let s: String = name
.chars()
.map(|c| if c == '/' || c == '\0' { '_' } else { c })
.collect();
if s.is_empty() { String::from("_") } else { s }
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::container::{ContainerBuilder, EntryMeta};
fn tmp(tag: &str) -> PathBuf {
let p = std::env::temp_dir().join(format!("omy-unpack-{tag}"));
let _ = std::fs::remove_dir_all(&p);
std::fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn extracts_files_dirs_and_empty_dirs() {
let root = tmp("basic");
let mut b = ContainerBuilder::new("myfolder");
b.add_dir(vec!["sub".into()], EntryMeta::default()).unwrap();
b.add_dir(vec!["empty".into()], EntryMeta::default())
.unwrap();
b.add_file(vec!["a.txt".into()], 3, None, EntryMeta::default())
.unwrap();
b.add_file(
vec!["sub".into(), "b.txt".into()],
4,
None,
EntryMeta::default(),
)
.unwrap();
let idx = b.finish().unwrap();
let rep = extract_container(&idx, b"aaabbbb", &root).unwrap();
assert_eq!(rep.files, 2);
assert_eq!(rep.dirs, 2);
assert!(rep.root.ends_with("myfolder"), "根名取自索引");
assert_eq!(
std::fs::read(rep.root.join("a.txt")).unwrap(),
b"aaa",
"区间必须对上:错了会解出别的文件的内容"
);
assert_eq!(std::fs::read(rep.root.join("sub/b.txt")).unwrap(), b"bbbb");
assert!(rep.root.join("empty").is_dir(), "空目录必须还原");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn metadata_is_restored_without_caller_doing_anything() {
let root = tmp("meta");
const T: i128 = 1_551_968_736_000_000_000;
let mut b = ContainerBuilder::new("f");
b.add_dir(vec!["sub".into()], EntryMeta {
mtime_ns: Some(T),
..EntryMeta::default()
})
.unwrap();
b.add_file(vec!["sub".into(), "a.txt".into()], 1, None, EntryMeta {
mtime_ns: Some(T),
..EntryMeta::default()
})
.unwrap();
let idx = b.finish().unwrap();
let rep = extract_container(&idx, b"x", &root).unwrap();
assert_eq!(rep.metadata.mtime_restored, 2, "文件和目录都该设上");
for rel in ["sub", "sub/a.txt"] {
let got = std::fs::metadata(rep.root.join(rel))
.unwrap()
.modified()
.unwrap();
let want = std::time::UNIX_EPOCH + std::time::Duration::from_nanos(T as u64);
let d = got
.duration_since(want)
.unwrap_or_else(|e| e.duration())
.as_secs();
assert!(d < 2, "{rel} 的 mtime 应当已还原,实际 {got:?}");
}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn hash_mismatch_is_caught_per_file() {
let root = tmp("hash");
let mut b = ContainerBuilder::new("f");
b.add_file(vec!["a.txt".into()], 3, Some([0u8; 32]), EntryMeta::default())
.unwrap();
let idx = b.finish().unwrap();
assert!(
extract_container(&idx, b"aaa", &root).is_err(),
"哈希不匹配必须报错,不能静默写出坏内容"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn payload_range_out_of_bounds_is_rejected() {
let root = tmp("oob");
let mut b = ContainerBuilder::new("f");
b.add_file(vec!["a.txt".into()], 999, None, EntryMeta::default())
.unwrap();
let idx = b.finish().unwrap();
assert!(extract_container(&idx, b"aaa", &root).is_err());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn symlinks_are_restored_or_reported_never_silently_dropped() {
let root = tmp("symlink");
let mut b = ContainerBuilder::new("f");
b.add_file(vec!["target.txt".into()], 3, None, EntryMeta::default())
.unwrap();
b.add_symlink(
vec!["link".into()],
"target.txt".into(),
EntryMeta::default(),
)
.unwrap();
let idx = b.finish().unwrap();
let rep = extract_container(&idx, b"abc", &root).unwrap();
let link = rep.root.join("link");
let unsupported_links = rep
.metadata
.unsupported
.iter()
.find(|(k, _)| *k == crate::restore::UnsupportedKind::Symlink)
.map(|(_, n)| *n)
.unwrap_or(0);
if rep.links == 1 {
let md = std::fs::symlink_metadata(&link).expect("链接该在磁盘上");
assert!(
md.file_type().is_symlink(),
"报告说建了链接,磁盘上却不是链接"
);
assert_eq!(
std::fs::read_link(&link).unwrap().to_string_lossy(),
"target.txt",
"目标要和存进去的一致,指错地方等于没还原"
);
assert_eq!(unsupported_links, 0, "建成功了就不该报不支持");
assert!(rep.metadata.failures.is_empty(), "建成功了不该有失败");
} else {
assert_eq!(rep.links, 0, "要么建成 1 个,要么 0 个");
assert_eq!(
unsupported_links, 1,
"建不出来必须报成 unsupported(换环境能还原),\
而不是 failures(让人反复重试)或干脆不报;实际 {:?} / {:?}",
rep.metadata.unsupported, rep.metadata.failures
);
assert!(
rep.metadata.failures.is_empty(),
"平台限制不该记进 failures,实际 {:?}",
rep.metadata.failures
);
}
assert_eq!(rep.files, 1);
assert_eq!(
std::fs::read(rep.root.join("target.txt")).unwrap(),
b"abc",
"链接的成败不该影响文件内容"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn link_to_dir_inside_container_gets_right_type() {
let root = tmp("linkorder");
let mut b = ContainerBuilder::new("f");
b.add_symlink(vec!["dlink".into()], "sub".into(), EntryMeta::default())
.unwrap();
b.add_symlink(
vec!["flink".into()],
"sub/inside.txt".into(),
EntryMeta::default(),
)
.unwrap();
b.add_dir(vec!["sub".into()], EntryMeta::default()).unwrap();
b.add_file(
vec!["sub".into(), "inside.txt".into()],
2,
None,
EntryMeta::default(),
)
.unwrap();
let idx = b.finish().unwrap();
let rep = extract_container(&idx, b"hi", &root).unwrap();
if rep.links == 0 {
let _ = std::fs::remove_dir_all(&root);
return;
}
assert_eq!(rep.links, 2, "两个链接都该建出来");
let dmd = std::fs::metadata(rep.root.join("dlink"))
.expect("目录链接跟随失败,说明类型选错了");
assert!(dmd.is_dir(), "dlink 跟随后该是目录");
let fmd = std::fs::metadata(rep.root.join("flink"))
.expect("文件链接跟随失败,说明类型选错了");
assert!(fmd.is_file(), "flink 跟随后该是文件");
assert_eq!(
std::fs::read(rep.root.join("flink")).expect("读文件链接失败"),
b"hi",
"透过文件链接该读到目标内容"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn symlink_entry_without_target_is_reported() {
let root = tmp("notarget");
let mut b = ContainerBuilder::new("f");
b.add_symlink(vec!["link".into()], "t".into(), EntryMeta::default())
.unwrap();
let mut idx = b.finish().unwrap();
for e in &mut idx.entries {
if e.kind == EntryKind::Symlink {
e.target = None;
}
}
let rep = extract_container(&idx, b"", &root).unwrap();
assert_eq!(rep.links, 0, "没目标建不出链接");
assert!(
rep.skipped.iter().any(|s| s.reason == "symlink_no_target"),
"必须如实报告,实际 {:?}",
rep.skipped
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn link_targets_pointing_outside_are_refused() {
let root = Path::new("/vault/root");
let link = root.join("sub").join("l");
for ok in ["a.txt", "./a.txt", "d/e.txt", "../sibling.txt", "../a/../b"] {
assert!(
link_target_stays_inside(root, &link, ok),
"{ok} 落在容器内,该放行"
);
}
for bad in [
"../../outside.txt",
"../../../../../../etc/passwd",
"d/../../../outside.txt",
] {
assert!(
!link_target_stays_inside(root, &link, bad),
"{bad} 爬出了容器,必须拒绝"
);
}
for abs in ["/etc/passwd", "/vault/root/a.txt"] {
assert!(
!link_target_stays_inside(root, &link, abs),
"{abs} 是绝对路径,必须拒绝"
);
}
}
#[cfg(windows)]
#[test]
fn windows_absolute_link_targets_are_refused() {
let root = Path::new(r"C:\vault\root");
let link = root.join("l");
for bad in [
r"C:\Windows\System32",
r"\\server\share\x",
r"\Windows",
r"C:\vault\root\a.txt",
] {
assert!(
!link_target_stays_inside(root, &link, bad),
"{bad} 必须拒绝"
);
}
}
#[test]
fn escaping_link_is_reported_not_created() {
let root = tmp("escape");
let mut b = ContainerBuilder::new("f");
b.add_symlink(
vec!["evil".into()],
"../../../../outside.txt".into(),
EntryMeta::default(),
)
.unwrap();
let idx = b.finish().unwrap();
let rep = extract_container(&idx, b"", &root).unwrap();
assert_eq!(rep.links, 0, "逃逸的链接不该被创建");
assert!(
rep.skipped
.iter()
.any(|s| s.reason == "symlink_escapes_root"),
"必须如实报告,实际 {:?}",
rep.skipped
);
assert!(
!rep.root.join("evil").exists(),
"磁盘上不该出现这个链接"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn sanitize_keeps_normal_names() {
assert_eq!(sanitize_filename("report.docx"), "report.docx");
assert_eq!(sanitize_filename("中文文件名.txt"), "中文文件名.txt");
}
#[cfg(windows)]
#[test]
fn sanitize_handles_windows_restrictions() {
assert_eq!(sanitize_filename("report:2026?.txt"), "report_2026_.txt");
assert_eq!(sanitize_filename("a<b>c"), "a_b_c");
assert_eq!(sanitize_filename("trailing."), "trailing");
assert_eq!(sanitize_filename("CON"), "_CON");
assert_eq!(sanitize_filename("con.txt"), "_con.txt");
assert_eq!(sanitize_filename(""), "_");
}
#[test]
fn safe_join_rejects_traversal() {
let root = Path::new("/tmp/root");
assert!(safe_join(root, &["..".to_string()]).is_err());
assert!(safe_join(root, &["a".to_string(), "..".to_string()]).is_err());
let (ok, adjusted) =
safe_join(root, &["a".to_string(), "b.txt".to_string()]).unwrap();
assert!(ok.starts_with(root));
assert!(!adjusted, "正常名字不该被标记为已调整");
}
#[cfg(windows)]
#[test]
fn adjusted_names_are_reported() {
let root = tmp("adjust");
let mut b = ContainerBuilder::new("f");
b.add_file(vec!["a?b.txt".into()], 1, None, EntryMeta::default())
.unwrap();
let idx = b.finish().unwrap();
let rep = extract_container(&idx, b"x", &root).unwrap();
assert_eq!(rep.adjusted, vec!["a?b.txt"], "改名必须报告");
assert!(rep.root.join("a_b.txt").exists(), "落盘用的是调整后的名字");
let _ = std::fs::remove_dir_all(&root);
}
}