use crate::time::extracted_system_time;
use crate::CliResult;
use rars::atomic64::AtomicU64;
use rars::{
Archive as DetectedArchive, ArchiveFamily, ArchiveVersion, AttrSource, Error,
ExtractedEntryMeta,
};
use std::collections::HashMap;
use std::fs::{self, File, OpenOptions};
use std::path::{Path, PathBuf};
use std::sync::atomic::Ordering;
use std::time::SystemTime;
pub(crate) struct ExtractionStaging {
pub(crate) root: PathBuf,
}
impl ExtractionStaging {
pub(crate) fn new(out_dir: &Path) -> std::io::Result<Self> {
static NEXT: AtomicU64 = AtomicU64::new(0);
fs::create_dir_all(out_dir)?;
for _ in 0..128 {
let root = out_dir.join(format!(
".rars-extract-{}-{:016x}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
let mut builder = fs::DirBuilder::new();
builder.recursive(false);
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
builder.mode(0o700);
}
match builder.create(&root) {
Ok(()) => return Ok(Self { root }),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error),
}
}
Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"could not create private extraction storage",
))
}
pub(crate) fn reject_alias(&self, destination: &Path) -> rars::Result<()> {
let storage = fs::canonicalize(&self.root)?;
let mut ancestor = Some(destination);
while let Some(path) = ancestor {
match fs::canonicalize(path) {
Ok(identity) => {
if identity.starts_with(&storage) {
return Err(Error::InvalidHeader(
"archive path conflicts with extraction storage",
));
}
return Ok(());
}
Err(error)
if matches!(
error.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) =>
{
ancestor = path.parent();
}
Err(error) => return Err(error.into()),
}
}
Ok(())
}
}
impl Drop for ExtractionStaging {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.root);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum OverwritePolicy {
Never,
Always,
}
pub(crate) struct ExtractedOutput {
pub(crate) name: Vec<u8>,
pub(crate) path: PathBuf,
pub(crate) meta: ExtractedEntryMeta,
pub(crate) family: ArchiveFamily,
pub(crate) restore_metadata: bool,
}
const FSREDIR_UNIX_SYMLINK: u64 = 0x01;
const FSREDIR_WINDOWS_SYMLINK: u64 = 0x02;
const FSREDIR_WINDOWS_JUNCTION: u64 = 0x03;
const FSREDIR_HARDLINK: u64 = 0x04;
const FSREDIR_FILE_COPY: u64 = 0x05;
#[cfg(windows)]
const FHEXTRA_REDIR_DIR: u64 = 0x01;
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum Backslash {
Separator,
Literal,
}
impl Backslash {
fn is_separator(self) -> bool {
self == Self::Separator
}
}
pub(crate) fn open_output_writer(
out_dir: &Path,
entry: &ExtractedEntryMeta,
overwrite: OverwritePolicy,
backslash: Backslash,
) -> rars::Result<(PathBuf, Box<dyn std::io::Write>)> {
let resolve = |name: &[u8]| {
relative_path(name, backslash.is_separator())
.map_err(|_| Error::InvalidHeader("unsafe archive path"))
};
let rel = resolve(&entry.name)?;
let mut out_path = checked_output_path(out_dir, &rel)?;
if entry.is_directory {
fs::create_dir_all(&out_path)?;
return Ok((out_path, Box::new(std::io::sink())));
}
fs::create_dir_all(
out_path
.parent()
.expect("validated output path has a parent"),
)?;
out_path = checked_output_path(out_dir, &rel)?;
Ok((
out_path.clone(),
Box::new(create_output_file(&out_path, overwrite)?),
))
}
pub(crate) fn output_path_for_entry(
out_dir: &Path,
entry: &ExtractedEntryMeta,
) -> rars::Result<PathBuf> {
let rel = output_relative_path(&entry.name)
.map_err(|_| Error::InvalidHeader("unsafe archive path"))?;
checked_output_path(out_dir, &rel)
}
pub(crate) fn rar50_entry_name(name: &[u8], host_os: u64) -> Vec<u8> {
const HOST5_UNIX: u64 = 1;
if cfg!(unix) && host_os == HOST5_UNIX {
return rars::filename::decode_rar50(name).into_owned();
}
name.iter()
.map(|&byte| if byte == b'\\' { b'_' } else { byte })
.collect()
}
pub(crate) fn output_path_for_rar50_entry(
out_dir: &Path,
entry: &rars::rar50::ExtractedEntryMeta,
) -> rars::Result<PathBuf> {
let rel = rar50_output_relative_path(&entry.name, entry.host_os)
.map_err(|_| Error::InvalidHeader("unsafe archive path"))?;
checked_output_path(out_dir, &rel)
}
pub(crate) fn rar50_output_relative_path(name: &[u8], host_os: u64) -> CliResult<PathBuf> {
relative_path(&rar50_entry_name(name, host_os), false)
}
pub(crate) fn create_rar50_redirection(
out_dir: &Path,
entry: &rars::rar50::ExtractedEntryMeta,
redirection: &rars::rar50::FileRedirection,
overwrite: OverwritePolicy,
created_paths: &HashMap<PathBuf, PathBuf>,
) -> rars::Result<(PathBuf, bool)> {
let prepare_destination = || -> rars::Result<PathBuf> {
let rel = rar50_output_relative_path(&entry.name, entry.host_os)
.map_err(|_| Error::InvalidHeader("unsafe archive path"))?;
let mut out_path = checked_output_path(out_dir, &rel)?;
fs::create_dir_all(
out_path
.parent()
.expect("validated output path has a parent"),
)?;
out_path = checked_output_path(out_dir, &rel)?;
prepare_redirection_destination(&out_path, overwrite)?;
Ok(out_path)
};
match redirection.redirection_type {
FSREDIR_UNIX_SYMLINK | FSREDIR_WINDOWS_SYMLINK | FSREDIR_WINDOWS_JUNCTION => {
let target_rel = rar50_output_relative_path(&redirection.target_name, entry.host_os)
.map_err(|_| Error::InvalidHeader("unsafe archive redirection target"))?;
let out_path = prepare_destination()?;
create_symlink_redirection(&target_rel, &out_path, redirection.flags)?;
Ok((out_path, false))
}
FSREDIR_HARDLINK => {
let source = redirection_source_path(redirection, entry.host_os, created_paths)?;
let out_path = prepare_destination()?;
fs::hard_link(source, &out_path)?;
Ok((out_path, true))
}
FSREDIR_FILE_COPY => {
let source = redirection_source_path(redirection, entry.host_os, created_paths)?;
let out_path = prepare_destination()?;
fs::copy(source, &out_path)?;
Ok((out_path, true))
}
_ => Err(Error::UnsupportedFeature {
version: ArchiveVersion::Rar50,
feature: "RAR 5 unsupported file redirection type",
}),
}
}
fn redirection_source_path<'a>(
redirection: &rars::rar50::FileRedirection,
host_os: u64,
created_paths: &'a HashMap<PathBuf, PathBuf>,
) -> rars::Result<&'a Path> {
let target = rar50_output_relative_path(&redirection.target_name, host_os)
.map_err(|_| Error::InvalidHeader("unsafe archive redirection target"))?;
created_paths
.get(&target)
.map(PathBuf::as_path)
.ok_or(Error::InvalidHeader(
"RAR 5 redirection target was not extracted earlier",
))
}
pub(crate) fn rar50_hardlink_source<'a>(
redirection: &rars::rar50::FileRedirection,
host_os: u64,
created_paths: &'a HashMap<PathBuf, PathBuf>,
) -> rars::Result<Option<&'a Path>> {
if redirection.redirection_type == FSREDIR_HARDLINK {
redirection_source_path(redirection, host_os, created_paths).map(Some)
} else {
Ok(None)
}
}
fn prepare_redirection_destination(path: &Path, overwrite: OverwritePolicy) -> std::io::Result<()> {
match fs::symlink_metadata(path) {
Ok(metadata) => match overwrite {
OverwritePolicy::Never => Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"file exists",
)),
OverwritePolicy::Always => {
if metadata.is_dir() {
fs::remove_dir(path)
} else {
fs::remove_file(path)
}
}
},
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error),
}
}
#[cfg(unix)]
fn create_symlink_redirection(target: &Path, link: &Path, _flags: u64) -> std::io::Result<()> {
std::os::unix::fs::symlink(target, link)
}
#[cfg(windows)]
fn create_symlink_redirection(target: &Path, link: &Path, flags: u64) -> std::io::Result<()> {
if flags & FHEXTRA_REDIR_DIR != 0 {
std::os::windows::fs::symlink_dir(target, link)
} else {
std::os::windows::fs::symlink_file(target, link)
}
}
#[cfg(not(any(unix, windows)))]
fn create_symlink_redirection(_target: &Path, _link: &Path, _flags: u64) -> std::io::Result<()> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"symbolic links are not supported on this platform",
))
}
pub(crate) fn restore_output_metadata(outputs: &[ExtractedOutput]) -> std::io::Result<()> {
for output in outputs
.iter()
.filter(|output| output.restore_metadata && !output.meta.is_directory)
{
if let Some(time) = extracted_system_time(
output.family,
output.meta.file_time,
output.meta.mtime_refinement,
) {
set_modified_time(&output.path, time)?;
}
set_extracted_permissions(
&output.path,
output.meta.file_attr,
output.meta.attr_source,
false,
)?;
}
let mut directories: Vec<_> = outputs
.iter()
.filter(|output| output.restore_metadata && output.meta.is_directory)
.collect();
directories.sort_by_cached_key(|output| std::cmp::Reverse(output.path.components().count()));
for output in directories {
if let Some(time) = extracted_system_time(
output.family,
output.meta.file_time,
output.meta.mtime_refinement,
) {
set_modified_time(&output.path, time)?;
}
set_extracted_permissions(
&output.path,
output.meta.file_attr,
output.meta.attr_source,
true,
)?;
}
Ok(())
}
#[cfg(windows)]
pub(crate) fn set_modified_time(path: &Path, time: SystemTime) -> std::io::Result<()> {
use std::os::windows::fs::OpenOptionsExt;
const FILE_WRITE_ATTRIBUTES: u32 = 0x0100;
const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
OpenOptions::new()
.access_mode(FILE_WRITE_ATTRIBUTES)
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
.open(path)?
.set_modified(time)
}
#[cfg(not(windows))]
pub(crate) fn set_modified_time(path: &Path, time: SystemTime) -> std::io::Result<()> {
File::open(path)?.set_modified(time)
}
#[cfg(unix)]
fn set_extracted_permissions(
path: &Path,
file_attr: u64,
attr_source: AttrSource,
is_directory: bool,
) -> std::io::Result<()> {
use std::os::unix::fs::PermissionsExt;
const FILE_ATTRIBUTE_READONLY: u64 = 0x01;
let mode = match attr_source {
AttrSource::Dos => {
(!is_directory && file_attr & FILE_ATTRIBUTE_READONLY != 0).then_some(0o444)
}
AttrSource::Unix if file_attr & 0o170000 != 0 => Some((file_attr & 0o777) as u32),
_ => None,
};
if let Some(mode) = mode {
fs::set_permissions(path, fs::Permissions::from_mode(mode))?;
}
Ok(())
}
#[cfg(not(unix))]
fn set_extracted_permissions(
_path: &Path,
_file_attr: u64,
_attr_source: AttrSource,
_is_directory: bool,
) -> std::io::Result<()> {
Ok(())
}
pub(crate) fn checked_output_path(out_dir: &Path, rel: &Path) -> rars::Result<PathBuf> {
rars::filename::checked_output_path(out_dir, rel).map_err(|error| match error {
Error::UnsafePath(message) => Error::InvalidHeader(message),
other => other,
})
}
pub(crate) fn print_ok_entry(entry: &ExtractedEntryMeta) {
println!(
"OK {}{}",
display_archive_bytes(&entry.name),
if entry.is_directory { "/" } else { "" }
);
}
pub(crate) fn warn_rar50_redirections(archive: &DetectedArchive) {
let DetectedArchive::Rar50Plus(archive) = archive else {
return;
};
for file in archive.files().filter(|file| file.redirection.is_some()) {
eprintln!("{}", redirection_warning(file.name_lossy()));
}
}
pub(crate) fn redirection_warning(name: impl AsRef<str>) -> String {
format!(
"warning: RAR 5 redirection entry '{}' is not recreated; extraction treats only regular file payloads as writable output",
display_archive_text(name.as_ref())
)
}
fn display_archive_text(text: impl AsRef<str>) -> String {
let mut out = String::new();
for ch in text.as_ref().chars() {
if ch.is_control() {
out.extend(ch.escape_default());
} else {
out.push(ch);
}
}
out
}
fn display_archive_bytes(bytes: &[u8]) -> String {
display_archive_text(String::from_utf8_lossy(bytes))
}
pub(crate) fn output_relative_path(name: &[u8]) -> CliResult<PathBuf> {
relative_path(name, true)
}
fn relative_path(name: &[u8], backslash_is_separator: bool) -> CliResult<PathBuf> {
match rars::filename::relative_path(name, backslash_is_separator) {
Ok(path) => Ok(path),
Err(Error::UnsafePath("unsafe archive path contains NUL byte")) => {
Err("unsafe archive path contains NUL byte".into())
}
Err(Error::InvalidHeader("empty archive path")) => Err("empty archive path".into()),
Err(Error::UnsafePath(_)) => {
let bytes: Vec<_> = name
.iter()
.map(|&byte| {
if backslash_is_separator && byte == b'\\' {
b'/'
} else {
byte
}
})
.collect();
let text = rars::filename::native_string(&bytes)?;
Err(format!("unsafe archive path: {}", Path::new(&text).display()).into())
}
Err(error) => Err(error.into()),
}
}
fn create_output_file(path: &Path, overwrite: OverwritePolicy) -> std::io::Result<File> {
let mut options = OpenOptions::new();
options.write(true);
match overwrite {
OverwritePolicy::Never => {
options.create_new(true);
}
OverwritePolicy::Always => {
options.create(true).truncate(true);
}
}
set_no_follow(&mut options);
options.open(path)
}
fn copy_verified_output(source: &Path, destination: &Path) -> std::io::Result<()> {
let file_type = fs::symlink_metadata(source)?.file_type();
if file_type.is_symlink() {
#[cfg(windows)]
let flags = {
use std::os::windows::fs::FileTypeExt;
if file_type.is_symlink_dir() {
FHEXTRA_REDIR_DIR
} else {
0
}
};
#[cfg(not(windows))]
let flags = 0;
return create_symlink_redirection(&fs::read_link(source)?, destination, flags);
}
let mut output = create_output_file(destination, OverwritePolicy::Never)?;
std::io::copy(&mut File::open(source)?, &mut output)?;
Ok(())
}
fn publish_no_replace(
source: &Path,
destination: &Path,
link: impl FnOnce(&Path, &Path) -> std::io::Result<()>,
) -> std::io::Result<()> {
if fs::symlink_metadata(source)?.file_type().is_symlink() {
return copy_verified_output(source, destination);
}
match link(source, destination) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Err(error),
Err(_) => copy_verified_output(source, destination),
}
}
pub(crate) fn publish_verified_output(
source: &Path,
destination: &Path,
overwrite: OverwritePolicy,
hardlink_target: Option<&Path>,
) -> rars::Result<()> {
publish_verified_output_with_rename(
source,
destination,
overwrite,
hardlink_target,
|from, to| fs::rename(from, to),
)
}
fn publish_verified_output_with_rename(
source: &Path,
destination: &Path,
overwrite: OverwritePolicy,
hardlink_target: Option<&Path>,
mut rename: impl FnMut(&Path, &Path) -> std::io::Result<()>,
) -> rars::Result<()> {
match overwrite {
OverwritePolicy::Never => publish_no_replace(source, destination, |from, to| {
fs::hard_link(hardlink_target.unwrap_or(from), to)
})?,
OverwritePolicy::Always => match rename(source, destination) {
Ok(()) => return Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::CrossesDevices => {
let staging = ExtractionStaging::new(
destination.parent().expect("validated path has parent"),
)?;
let local = staging.root.join("payload");
let linked =
hardlink_target.is_some_and(|target| fs::hard_link(target, &local).is_ok());
if !linked {
copy_verified_output(source, &local)?;
}
rename(&local, destination)?;
}
Err(error) => return Err(error.into()),
},
}
remove_staged_output(source)?;
Ok(())
}
fn remove_staged_output(source: &Path) -> std::io::Result<()> {
#[cfg(windows)]
{
use std::os::windows::fs::FileTypeExt;
if fs::symlink_metadata(source)?.file_type().is_symlink_dir() {
return fs::remove_dir(source);
}
}
fs::remove_file(source)
}
#[cfg(unix)]
fn set_no_follow(options: &mut OpenOptions) {
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW);
}
#[cfg(not(unix))]
fn set_no_follow(_options: &mut OpenOptions) {
}
#[cfg(test)]
mod tests {
use super::{
display_archive_text, output_relative_path, rar50_entry_name, rar50_output_relative_path,
restore_output_metadata, ExtractedOutput,
};
use rars::{ArchiveFamily, ExtractedEntryMeta};
use std::fs;
fn scratch(name: &str) -> crate::scratch::Scratch {
crate::scratch::case(&format!("rars-output-{name}"))
}
#[test]
fn verified_copy_fallback_keeps_no_overwrite_without_hardlinks() {
use super::*;
let root = scratch("no-hardlink-publication");
let source = root.join("source");
let destination = root.join("destination");
fs::write(&source, b"verified bytes").unwrap();
let unsupported =
|_: &Path, _: &Path| Err(std::io::Error::from(std::io::ErrorKind::Unsupported));
publish_no_replace(&source, &destination, unsupported).unwrap();
assert_eq!(fs::read(&destination).unwrap(), b"verified bytes");
fs::write(&source, b"replacement").unwrap();
assert_eq!(
publish_no_replace(&source, &destination, unsupported)
.unwrap_err()
.kind(),
std::io::ErrorKind::AlreadyExists
);
assert_eq!(fs::read(destination).unwrap(), b"verified bytes");
}
#[test]
fn cross_device_publication_stages_beside_the_destination() {
use super::*;
let root = scratch("cross-device-publication");
let source = root.join("source");
let parent = root.join("other-mount");
fs::create_dir(&parent).unwrap();
let destination = parent.join("destination");
fs::write(&source, b"verified bytes").unwrap();
fs::write(&destination, b"old contents").unwrap();
let mut calls = 0;
publish_verified_output_with_rename(
&source,
&destination,
OverwritePolicy::Always,
None,
|from, to| {
calls += 1;
if calls == 1 {
return Err(std::io::Error::from(std::io::ErrorKind::CrossesDevices));
}
assert!(from.starts_with(&parent));
assert_eq!(fs::read(to).unwrap(), b"old contents");
fs::rename(from, to)
},
)
.unwrap();
assert_eq!(calls, 2);
assert_eq!(fs::read(&destination).unwrap(), b"verified bytes");
assert!(!source.exists());
assert_eq!(fs::read_dir(parent).unwrap().count(), 1);
}
#[cfg(unix)]
#[test]
fn cross_device_publication_preserves_archive_hardlinks() {
use super::*;
use std::os::unix::fs::MetadataExt;
for overwrite in [OverwritePolicy::Never, OverwritePolicy::Always] {
let root = scratch("cross-device-hardlinks");
let staging = ExtractionStaging::new(&root).unwrap();
let source = staging.root.join("source");
let source_link = staging.root.join("link");
fs::write(&source, b"verified bytes").unwrap();
fs::hard_link(&source, &source_link).unwrap();
let parent = root.join("other-mount");
fs::create_dir(&parent).unwrap();
let target = parent.join("target");
let destination = parent.join("link");
publish_verified_output_with_rename(
&source,
&target,
OverwritePolicy::Always,
None,
|from, to| {
if from.starts_with(&staging.root) {
Err(std::io::Error::from(std::io::ErrorKind::CrossesDevices))
} else {
fs::rename(from, to)
}
},
)
.unwrap();
publish_verified_output_with_rename(
&source_link,
&destination,
overwrite,
Some(&target),
|from, to| {
if from.starts_with(&staging.root) {
Err(std::io::Error::from(std::io::ErrorKind::CrossesDevices))
} else {
fs::rename(from, to)
}
},
)
.unwrap();
let original = fs::metadata(&target).unwrap();
let linked = fs::metadata(&destination).unwrap();
assert_eq!(
(original.dev(), original.ino()),
(linked.dev(), linked.ino())
);
assert_eq!(fs::read(destination).unwrap(), b"verified bytes");
}
}
#[cfg(any(unix, windows))]
#[test]
fn publication_cleans_staged_directory_symlinks() {
use super::*;
for overwrite in [OverwritePolicy::Never, OverwritePolicy::Always] {
let root = scratch("directory-symlink-publication");
let directory = root.join("directory");
fs::create_dir(&directory).unwrap();
let source = root.join("source-link");
let destination = root.join("published-link");
let result = create_symlink_redirection(&directory, &source, 1);
#[cfg(windows)]
if result
.as_ref()
.is_err_and(|error| error.raw_os_error() == Some(1314))
{
return;
}
result.unwrap();
let mut calls = 0;
publish_verified_output_with_rename(
&source,
&destination,
overwrite,
None,
|from, to| {
calls += 1;
if calls == 1 {
Err(std::io::Error::from(std::io::ErrorKind::CrossesDevices))
} else {
fs::rename(from, to)
}
},
)
.unwrap();
assert!(fs::symlink_metadata(source).is_err());
assert_eq!(fs::read_link(destination).unwrap(), directory);
}
}
#[test]
fn staging_aliases_are_rejected_using_existing_filesystem_ancestors() {
use super::*;
let root = scratch("staging-alias");
let mut state = crate::ExtractOutputState::new(
&root,
OverwritePolicy::Always,
ArchiveFamily::Rar50Plus,
);
state
.open_entry(&rars::ExtractedEntryMeta::new(
b"ordinary".to_vec(),
None,
0,
false,
))
.unwrap();
let storage = state.staging.as_ref().unwrap().root.clone();
let alias = storage
.file_name()
.unwrap()
.to_string_lossy()
.to_uppercase();
let aliases_storage = root.join(&alias).exists();
let name = format!("{alias}/payload").into_bytes();
let result = state.open_entry(&rars::ExtractedEntryMeta::new(name, None, 0, false));
if aliases_storage {
assert!(matches!(
result,
Err(Error::InvalidHeader(
"archive path conflicts with extraction storage"
))
));
assert_eq!(fs::read_dir(&storage).unwrap().count(), 0);
assert!(root.join("ordinary").exists());
} else {
assert!(result.is_ok());
}
}
#[test]
fn path_validation_rejects_empty_drive_root_and_parent_names() {
use super::*;
let dir = scratch("path-validation");
for name in [
b"".as_slice(),
b".",
b"./",
b"C:relative",
b"z:/absolute",
b"/absolute",
b"../escape",
b"name\0suffix",
] {
assert!(output_relative_path(name).is_err(), "{name:?}");
let meta = ExtractedEntryMeta::new(name.to_vec(), None, 0, false);
assert!(output_path_for_entry(&dir, &meta).is_err());
let (mut rar50_meta, _) = redirection_fixture();
rar50_meta.name = name.to_vec();
assert!(output_path_for_rar50_entry(&dir, &rar50_meta).is_err());
}
for path in [
Path::new("../escape"),
Path::new("/absolute"),
Path::new("."),
] {
assert!(checked_output_path(&dir, path).is_err());
}
assert_eq!(
output_relative_path(b"./nested/./file").unwrap(),
Path::new("nested/file")
);
}
#[cfg(unix)]
#[test]
fn writers_refuse_symlink_parents_and_redirections_report_permission_errors() {
use super::*;
use std::os::unix::fs::{symlink, PermissionsExt};
let dir = scratch("output-filesystem-errors");
let target = dir.join("target");
fs::create_dir(&target).unwrap();
symlink(&target, dir.join("link")).unwrap();
let meta = ExtractedEntryMeta::new(b"link/child".to_vec(), None, 0, false);
assert!(
open_output_writer(&dir, &meta, OverwritePolicy::Always, Backslash::Separator).is_err()
);
assert_eq!(fs::read_dir(&target).unwrap().count(), 0);
let (mut link_meta, mut link_redirection) = redirection_fixture();
link_meta.name = b"link/child".to_vec();
link_redirection.redirection_type = FSREDIR_UNIX_SYMLINK;
link_redirection.target_name = b"target".to_vec();
assert!(create_rar50_redirection(
&dir,
&link_meta,
&link_redirection,
OverwritePolicy::Always,
&HashMap::new()
)
.is_err());
assert_eq!(fs::read_dir(&target).unwrap().count(), 0);
let locked = dir.join("locked");
fs::create_dir(&locked).unwrap();
fs::set_permissions(&locked, fs::Permissions::from_mode(0o500)).unwrap();
let permission_bits_enforced = fs::write(locked.join("probe"), b"probe").is_err();
let (mut meta, mut redirection) = redirection_fixture();
meta.name = b"locked/child".to_vec();
redirection.redirection_type = FSREDIR_UNIX_SYMLINK;
redirection.target_name = b"target".to_vec();
let result = create_rar50_redirection(
&dir,
&meta,
&redirection,
OverwritePolicy::Always,
&HashMap::new(),
);
fs::set_permissions(&locked, fs::Permissions::from_mode(0o700)).unwrap();
if !permission_bits_enforced {
eprintln!("permission-denial case unavailable on privileged runner");
return;
}
assert!(result.is_err());
assert!(!locked.join("child").exists());
}
#[test]
fn output_creation_preserves_existing_files_on_refusal() {
use super::*;
let dir = scratch("writer-errors");
let mut meta = ExtractedEntryMeta::new(b"existing".to_vec(), None, 0, false);
fs::write(dir.join("existing"), b"keep").unwrap();
assert!(
open_output_writer(&dir, &meta, OverwritePolicy::Never, Backslash::Separator).is_err()
);
assert_eq!(fs::read(dir.join("existing")).unwrap(), b"keep");
for name in [b"../escape".as_slice(), b"invalid\0name"] {
meta.name = name.to_vec();
assert!(
open_output_writer(&dir, &meta, OverwritePolicy::Always, Backslash::Separator)
.is_err()
);
}
meta.name = b"existing/child".to_vec();
for directory in [false, true] {
meta.is_directory = directory;
assert!(
open_output_writer(&dir, &meta, OverwritePolicy::Always, Backslash::Separator)
.is_err()
);
}
assert_eq!(fs::read(dir.join("existing")).unwrap(), b"keep");
}
#[test]
fn redirections_replace_empty_directories_but_preserve_nonempty_ones() {
use super::*;
let dir = scratch("redirection-directories");
let (mut meta, mut redirection) = redirection_fixture();
meta.name = b"destination".to_vec();
redirection.redirection_type = FSREDIR_FILE_COPY;
redirection.target_name = b"source".to_vec();
let source = dir.join("source");
fs::write(&source, b"payload").unwrap();
let created = HashMap::from([(PathBuf::from("source"), source)]);
let destination = dir.join("destination");
fs::create_dir(&destination).unwrap();
assert!(create_rar50_redirection(
&dir,
&meta,
&redirection,
OverwritePolicy::Never,
&created
)
.is_err());
assert!(destination.is_dir());
fs::write(destination.join("keep"), b"keep").unwrap();
assert!(create_rar50_redirection(
&dir,
&meta,
&redirection,
OverwritePolicy::Always,
&created
)
.is_err());
assert_eq!(fs::read(destination.join("keep")).unwrap(), b"keep");
fs::remove_file(destination.join("keep")).unwrap();
create_rar50_redirection(&dir, &meta, &redirection, OverwritePolicy::Always, &created)
.unwrap();
assert_eq!(fs::read(&destination).unwrap(), b"payload");
meta.name = b"destination/child".to_vec();
assert!(matches!(
create_rar50_redirection(&dir, &meta, &redirection, OverwritePolicy::Always, &created),
Err(Error::Io(_))
));
assert!(!destination.join("child").exists());
assert_eq!(fs::read(&destination).unwrap(), b"payload");
}
#[test]
fn verified_redirection_publication_replaces_only_empty_directories() {
use super::*;
use std::io::Write;
let mut kinds = vec![FSREDIR_FILE_COPY, FSREDIR_HARDLINK];
if cfg!(unix) {
kinds.push(FSREDIR_UNIX_SYMLINK);
}
for kind in kinds {
for nonempty in [false, true] {
for overwrite in [OverwritePolicy::Never, OverwritePolicy::Always] {
let root = scratch("verified-redirection-directory");
let destination = root.join("destination");
fs::create_dir(&destination).unwrap();
if nonempty {
fs::write(destination.join("keep"), b"keep").unwrap();
}
let mut state =
crate::ExtractOutputState::new(&root, overwrite, ArchiveFamily::Rar50Plus);
let (mut meta, mut redirection) = redirection_fixture();
meta.name = b"source".to_vec();
state
.open_rar50_entry(&meta)
.unwrap()
.write_all(b"payload")
.unwrap();
meta.name = b"destination".to_vec();
redirection.target_name = b"source".to_vec();
redirection.redirection_type = kind;
let staged = state.create_rar50_redirection(&meta, &redirection);
assert!(
destination.is_dir(),
"destination changed before verification"
);
if overwrite == OverwritePolicy::Never {
assert!(staged.is_err());
assert_eq!(fs::read(root.join("source")).unwrap(), b"payload");
continue;
}
staged.unwrap();
let published = state.publish();
if nonempty {
assert!(published.is_err());
assert_eq!(fs::read(destination.join("keep")).unwrap(), b"keep");
} else {
published.unwrap();
assert_eq!(fs::read(&destination).unwrap(), b"payload");
}
}
}
}
}
#[test]
fn redirection_filesystem_and_path_errors_are_reported() {
use super::*;
let dir = scratch("redirection-errors");
let (mut meta, mut redirection) = redirection_fixture();
meta.name = b"destination".to_vec();
let destination = dir.join("destination");
fs::write(&destination, b"keep").unwrap();
for kind in [FSREDIR_HARDLINK, FSREDIR_FILE_COPY] {
redirection.redirection_type = kind;
redirection.target_name = b"../escape".to_vec();
assert!(create_rar50_redirection(
&dir,
&meta,
&redirection,
OverwritePolicy::Always,
&HashMap::new()
)
.is_err());
assert_eq!(fs::read(&destination).unwrap(), b"keep");
}
redirection.redirection_type = FSREDIR_FILE_COPY;
redirection.target_name = b"source".to_vec();
let source = dir.join("source");
fs::write(&source, b"payload").unwrap();
let created = HashMap::from([(PathBuf::from("source"), source.clone())]);
for name in [b"../escape".as_slice(), b"destination/child"] {
meta.name = name.to_vec();
for kind in [FSREDIR_FILE_COPY, FSREDIR_HARDLINK, FSREDIR_UNIX_SYMLINK] {
redirection.redirection_type = kind;
assert!(create_rar50_redirection(
&dir,
&meta,
&redirection,
OverwritePolicy::Always,
&created
)
.is_err());
assert_eq!(fs::read(&destination).unwrap(), b"keep");
}
}
fs::remove_file(&source).unwrap();
meta.name = b"new-destination".to_vec();
for kind in [FSREDIR_HARDLINK, FSREDIR_FILE_COPY] {
redirection.redirection_type = kind;
assert!(create_rar50_redirection(
&dir,
&meta,
&redirection,
OverwritePolicy::Always,
&created
)
.is_err());
assert!(!dir.join("new-destination").exists());
}
}
#[test]
fn metadata_restoration_errors_and_opt_out_are_observable() {
use super::*;
let dir = scratch("metadata-errors");
for directory in [false, true] {
for timestamp in [None, Some(1_704_067_200)] {
let output = ExtractedOutput {
name: b"missing".to_vec(),
path: dir.join("missing"),
meta: ExtractedEntryMeta::new(
b"missing".to_vec(),
timestamp,
if directory { 0o040700 } else { 0o100600 },
directory,
)
.with_attr_source(AttrSource::Unix),
family: ArchiveFamily::Rar50Plus,
restore_metadata: false,
};
restore_output_metadata(std::slice::from_ref(&output)).unwrap();
#[cfg(unix)]
{
let output = ExtractedOutput {
restore_metadata: true,
..output
};
assert!(restore_output_metadata(&[output]).is_err());
}
}
}
}
#[cfg(unix)]
#[test]
fn permissions_respect_attribute_source_and_type_bits() {
use super::*;
use std::os::unix::fs::PermissionsExt;
let dir = scratch("permission-policy");
for (source, attr, directory, expected) in [
(AttrSource::Dos, 0, false, 0o640),
(AttrSource::Dos, 1, false, 0o444),
(AttrSource::Dos, 1, true, 0o640),
(AttrSource::Unix, 0o777, false, 0o640),
(AttrSource::Unix, 0o100754, false, 0o754),
(AttrSource::Unix, u64::MAX, false, 0o777),
(AttrSource::Unknown, u64::MAX, false, 0o640),
] {
let path = dir.join("entry");
if path.exists() {
if path.is_dir() {
fs::remove_dir(&path).unwrap();
} else {
fs::remove_file(&path).unwrap();
}
}
if directory {
fs::create_dir(&path).unwrap();
} else {
fs::write(&path, b"payload").unwrap();
}
fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
set_extracted_permissions(&path, attr, source, directory).unwrap();
assert_eq!(
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
expected
);
fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap();
}
}
fn redirection_fixture() -> (
rars::rar50::ExtractedEntryMeta,
rars::rar50::FileRedirection,
) {
let archive = rars::rar50::Archive::parse(include_bytes!(
"../../rars/tests/fixtures/rar50/wild/hardlink.rar"
))
.unwrap();
let file = archive
.files()
.find(|file| file.redirection.is_some())
.unwrap();
(file.metadata(), file.redirection.clone().unwrap())
}
#[cfg(unix)]
#[test]
fn rar50_mapped_names_are_resolved_once_for_files_and_redirections() {
use super::*;
use std::ffi::OsStr;
use std::io::Write;
use std::os::unix::ffi::OsStrExt;
for kind in [FSREDIR_HARDLINK, FSREDIR_FILE_COPY, FSREDIR_UNIX_SYMLINK] {
let dir = scratch("mapped-redirections");
let mut state = crate::ExtractOutputState::new(
&dir,
OverwritePolicy::Always,
ArchiveFamily::Rar50Plus,
);
let (mut meta, mut redirection) = redirection_fixture();
meta.host_os = 1;
let literal = "source-\u{fffe}\u{e0ff}".as_bytes();
meta.name = rars::filename::encode_rar50(literal).into_owned();
assert_eq!(
rar50_output_relative_path(&meta.name, meta.host_os)
.unwrap()
.as_os_str()
.as_bytes(),
literal
);
let probe = dir.join(OsStr::from_bytes(literal));
let native = match fs::write(&probe, []) {
Ok(()) => {
fs::remove_file(probe).unwrap();
literal
}
Err(error) if error.raw_os_error() == Some(libc::EILSEQ) => {
"source-\u{e0ff}".as_bytes()
}
Err(error) => panic!("literal filename probe failed unexpectedly: {error}"),
};
meta.name = rars::filename::encode_rar50(native).into_owned();
state
.open_rar50_entry(&meta)
.unwrap()
.write_all(b"payload")
.unwrap();
assert_eq!(state.outputs[0].name, meta.name);
redirection.target_name = meta.name.clone();
redirection.redirection_type = kind;
let link = if crate::scratch::supports_raw_byte_names(&dir) {
b"link-\xff".as_slice()
} else {
b"link-unicode".as_slice()
};
meta.name = rars::filename::encode_rar50(link).into_owned();
state.create_rar50_redirection(&meta, &redirection).unwrap();
state.publish().unwrap();
assert_eq!(
fs::read(dir.join(OsStr::from_bytes(link))).unwrap(),
b"payload"
);
assert_eq!(
fs::read(dir.join(OsStr::from_bytes(native))).unwrap(),
b"payload"
);
}
}
#[test]
fn rar50_redirections_keep_backslash_names_distinct_from_directories() {
use super::*;
use std::io::Write;
for host_os in [0, 1] {
for kind in [FSREDIR_HARDLINK, FSREDIR_FILE_COPY, FSREDIR_UNIX_SYMLINK] {
if kind == FSREDIR_UNIX_SYMLINK && !cfg!(unix) {
continue;
}
let dir = scratch("rar50-redirection-paths");
let mut state = crate::ExtractOutputState::new(
&dir,
OverwritePolicy::Always,
ArchiveFamily::Rar50Plus,
);
let (mut meta, mut redirection) = redirection_fixture();
meta.host_os = host_os;
for (name, data) in [
(&br"sub\file"[..], &b"literal"[..]),
(b"sub/file", b"nested"),
(b"links/item", b"existing destination"),
] {
meta.name = name.to_vec();
state
.open_rar50_entry(&meta)
.unwrap()
.write_all(data)
.unwrap();
}
meta.name = br"links\item".to_vec();
redirection.redirection_type = kind;
redirection.target_name = br"sub\file".to_vec();
state.create_rar50_redirection(&meta, &redirection).unwrap();
let (source, destination) = if cfg!(unix) && host_os == 1 {
(r"sub\file", r"links\item")
} else {
("sub_file", "links_item")
};
if kind != FSREDIR_UNIX_SYMLINK {
meta.name = b"chain".to_vec();
redirection.target_name = br"links\item".to_vec();
state.create_rar50_redirection(&meta, &redirection).unwrap();
}
state.publish().unwrap();
assert_eq!(fs::read(dir.join(destination)).unwrap(), b"literal");
assert_eq!(fs::read(dir.join("sub/file")).unwrap(), b"nested");
assert_eq!(
fs::read(dir.join("links/item")).unwrap(),
b"existing destination"
);
if kind == FSREDIR_UNIX_SYMLINK {
assert_eq!(
fs::read_link(dir.join(destination)).unwrap(),
Path::new(source)
);
} else {
assert_eq!(fs::read(dir.join("chain")).unwrap(), b"literal");
}
}
}
}
#[test]
fn rar50_redirections_reject_colliding_output_names() {
use super::*;
use std::io::Write;
for host_os in [0, 1] {
let dir = scratch("rar50-redirection-collision");
let mut state = crate::ExtractOutputState::new(
&dir,
OverwritePolicy::Always,
ArchiveFamily::Rar50Plus,
);
let (mut meta, mut redirection) = redirection_fixture();
meta.host_os = host_os;
meta.name = br"same\name".to_vec();
state
.open_rar50_entry(&meta)
.unwrap()
.write_all(b"keep me")
.unwrap();
let name = if cfg!(unix) && host_os == 1 {
r"same\name"
} else {
"same_name"
};
meta.name = format!("./{name}").into_bytes();
redirection.redirection_type = FSREDIR_FILE_COPY;
redirection.target_name = br"same\name".to_vec();
assert!(matches!(
state.create_rar50_redirection(&meta, &redirection),
Err(Error::InvalidHeader(
"multiple archive entries map to the same output path"
))
));
state.publish().unwrap();
assert_eq!(fs::read(dir.join(name)).unwrap(), b"keep me");
}
}
#[test]
fn invalid_redirections_preserve_existing_destinations() {
use super::*;
let dir = scratch("invalid-redirections");
let (mut meta, mut redirection) = redirection_fixture();
meta.name = b"destination".to_vec();
let destination = dir.join("destination");
for (kind, target) in [
(99, &b"source"[..]),
(FSREDIR_UNIX_SYMLINK, b"../escape"),
(FSREDIR_HARDLINK, b"missing"),
(FSREDIR_FILE_COPY, b"missing"),
] {
fs::write(&destination, b"keep me").unwrap();
redirection.redirection_type = kind;
redirection.target_name = target.to_vec();
assert!(create_rar50_redirection(
&dir,
&meta,
&redirection,
OverwritePolicy::Always,
&HashMap::new(),
)
.is_err());
assert_eq!(fs::read(&destination).unwrap(), b"keep me", "type {kind}");
}
}
#[test]
fn valid_copy_redirection_obeys_overwrite_policy() {
use super::*;
let dir = scratch("copy-redirection");
let (mut meta, mut redirection) = redirection_fixture();
meta.name = b"destination".to_vec();
redirection.redirection_type = FSREDIR_FILE_COPY;
redirection.target_name = b"source".to_vec();
let destination = dir.join("destination");
let source = dir.join("source");
fs::write(&destination, b"old").unwrap();
fs::write(&source, b"new").unwrap();
let created = HashMap::from([(PathBuf::from("source"), source)]);
assert!(create_rar50_redirection(
&dir,
&meta,
&redirection,
OverwritePolicy::Never,
&created,
)
.is_err());
assert_eq!(fs::read(&destination).unwrap(), b"old");
create_rar50_redirection(&dir, &meta, &redirection, OverwritePolicy::Always, &created)
.unwrap();
assert_eq!(fs::read(&destination).unwrap(), b"new");
}
#[test]
fn rar50_backslash_is_a_character_and_legacy_backslash_is_a_separator() {
const UNIX: u64 = 1;
const WINDOWS: u64 = 0;
let unix_host_name = if cfg!(unix) {
br"sub\nam.t".as_slice()
} else {
b"sub_nam.t".as_slice()
};
let unix_host_path = if cfg!(unix) {
std::path::Path::new(r"sub\nam.t")
} else {
std::path::Path::new("sub_nam.t")
};
assert_eq!(rar50_entry_name(br"sub\nam.t", UNIX), unix_host_name);
assert_eq!(
rar50_entry_name(br"sub\nam.t", WINDOWS),
b"sub_nam.t".to_vec()
);
assert_eq!(
rar50_output_relative_path(br"sub\nam.t", UNIX).unwrap(),
unix_host_path
);
assert_eq!(
rar50_output_relative_path(br"sub\nam.t", WINDOWS).unwrap(),
std::path::Path::new("sub_nam.t")
);
assert_eq!(
rar50_output_relative_path(b"dir/nam.t", UNIX).unwrap(),
std::path::Path::new("dir/nam.t")
);
assert_eq!(
output_relative_path(br"sub\nam.t").unwrap(),
std::path::Path::new("sub/nam.t")
);
}
#[test]
fn rar50_backslash_traversal_is_still_refused() {
const UNIX: u64 = 1;
const WINDOWS: u64 = 0;
let mapped = rar50_output_relative_path(br"..\..\pwn", UNIX).unwrap();
assert_eq!(
mapped,
if cfg!(unix) {
std::path::Path::new(r"..\..\pwn")
} else {
std::path::Path::new(".._.._pwn")
}
);
assert_eq!(mapped.components().count(), 1);
for host in [UNIX, WINDOWS] {
for name in [b"../../pwn".as_slice(), br"../..\pwn", b"/pwn"] {
assert!(rar50_output_relative_path(name, host).is_err());
}
}
assert!(output_relative_path(br"..\..\pwn").is_err());
}
#[test]
fn display_archive_text_keeps_unicode_and_escapes_controls() {
assert_eq!(
display_archive_text("ваапап/WinRAR.exe"),
"ваапап/WinRAR.exe"
);
assert_eq!(
display_archive_text("x\u{1b}]0;evil\u{7}"),
"x\\u{1b}]0;evil\\u{7}"
);
}
#[test]
fn restore_output_metadata_updates_file_and_directory_times() {
let root = scratch("metadata-times");
let file = root.join("payload.exe");
let dir = root.join("nested");
fs::write(&file, b"payload").unwrap();
fs::create_dir(&dir).unwrap();
let outputs = [
ExtractedOutput {
name: b"payload.exe".to_vec(),
path: file,
meta: ExtractedEntryMeta::new(b"payload.exe".to_vec(), 1_704_067_200, 0x20, false),
family: ArchiveFamily::Rar50Plus,
restore_metadata: true,
},
ExtractedOutput {
name: b"nested".to_vec(),
path: dir,
meta: ExtractedEntryMeta::new(b"nested".to_vec(), 1_704_067_200, 0x10, true),
family: ArchiveFamily::Rar50Plus,
restore_metadata: true,
},
];
restore_output_metadata(&outputs).unwrap();
}
#[cfg(unix)]
#[test]
fn restore_directory_metadata_handles_restrictive_modes_in_any_entry_order() {
use std::os::unix::fs::PermissionsExt;
use std::time::{Duration, UNIX_EPOCH};
let root = scratch("restrictive-directory-metadata");
let paths = [
root.join("parent"),
root.join("parent/child"),
root.join("parent/child/leaf"),
];
for path in &paths {
fs::create_dir(path).unwrap();
fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap();
}
let outputs: Vec<_> = [0, 2, 1]
.into_iter()
.map(|index| {
let name = paths[index]
.file_name()
.unwrap()
.as_encoded_bytes()
.to_vec();
ExtractedOutput {
name: name.clone(),
path: paths[index].clone(),
meta: ExtractedEntryMeta::new(
name,
1_704_067_200 + index as u32,
0o040000,
true,
)
.with_attr_source(rars::AttrSource::Unix),
family: ArchiveFamily::Rar50Plus,
restore_metadata: true,
}
})
.collect();
let result = restore_output_metadata(&outputs);
let mut metadata = Vec::new();
for path in &paths {
metadata.push(fs::metadata(path).unwrap());
fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap();
}
result.unwrap();
for (index, meta) in metadata.iter().enumerate() {
assert_eq!(meta.permissions().mode() & 0o777, 0);
assert_eq!(
meta.modified().unwrap(),
UNIX_EPOCH + Duration::from_secs(1_704_067_200 + index as u64)
);
}
}
}