Files
relicario/crates/relicario-core/tests/safe_unpack.rs
adlee-was-taken 4d02a50cc8 chore(core): fix pre-existing clippy warnings (-D warnings gate)
Resolves pre-existing lint issues in imgsecret.rs, time.rs, totp.rs,
and crypto.rs that blocked the cargo clippy --workspace -D warnings
gate. No logic changes: loop-index → iterator, manual div_ceil →
.div_ceil(), manual range contains → .contains(), auto-deref cleanup.

Also fixes pre-existing warnings in relicario-cli (main.rs, session.rs,
device.rs, gitea.rs, helpers.rs, test helpers): dead_code suppression,
too_many_arguments, literal_with_empty_format_string, manual_char_cmp,
map_or → is_none_or, and repeat().take() → vec! in test helpers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 19:32:45 -04:00

188 lines
5.8 KiB
Rust

use std::path::PathBuf;
use tar::{Builder, Header, EntryType};
use relicario_core::safe_unpack_git_archive;
/// Craft a raw POSIX ustar tar with a single entry using the given raw path bytes.
/// The tar crate's `Builder` sanitises paths, so we write the 512-byte header
/// manually to produce truly malicious archives.
fn raw_tar_with_path(raw_path: &[u8], content: &[u8]) -> Vec<u8> {
let mut buf = vec![0u8; 512]; // one header block
// Bytes 0-99: name field (null-padded)
let name_len = raw_path.len().min(100);
buf[..name_len].copy_from_slice(&raw_path[..name_len]);
// Bytes 100-107: mode = "0000644\0"
buf[100..108].copy_from_slice(b"0000644\0");
// Bytes 108-115: uid
buf[108..116].copy_from_slice(b"0000000\0");
// Bytes 116-123: gid
buf[116..124].copy_from_slice(b"0000000\0");
// Bytes 124-135: size (octal, 11 digits + null)
let size_str = format!("{:011o}\0", content.len());
buf[124..136].copy_from_slice(size_str.as_bytes());
// Bytes 136-147: mtime
buf[136..148].copy_from_slice(b"00000000000\0");
// Bytes 148-155: checksum placeholder (spaces during compute)
buf[148..156].copy_from_slice(b" ");
// Byte 156: typeflag = '0' (regular file)
buf[156] = b'0';
// Bytes 257-262: magic "ustar\0"
buf[257..263].copy_from_slice(b"ustar\0");
// Bytes 263-264: version "00"
buf[263..265].copy_from_slice(b"00");
// Compute checksum (sum of all bytes, checksum field treated as spaces).
let checksum: u32 = buf.iter().map(|&b| b as u32).sum();
let cksum_str = format!("{:06o}\0 ", checksum);
buf[148..156].copy_from_slice(cksum_str.as_bytes());
// Append padded content blocks.
let mut out = buf;
if !content.is_empty() {
out.extend_from_slice(content);
// Pad to 512-byte boundary.
let remainder = content.len() % 512;
if remainder != 0 {
out.extend(vec![0u8; 512 - remainder]);
}
}
// Two zero blocks = end-of-archive.
out.extend(vec![0u8; 1024]);
out
}
/// Build a tar with a raw symlink entry (typeflag = '2').
fn raw_symlink_tar() -> Vec<u8> {
let mut buf = vec![0u8; 512];
// name
buf[..9].copy_from_slice(b"evil_link");
// mode
buf[100..108].copy_from_slice(b"0000755\0");
// uid/gid
buf[108..116].copy_from_slice(b"0000000\0");
buf[116..124].copy_from_slice(b"0000000\0");
// size = 0
buf[124..136].copy_from_slice(b"00000000000\0");
// mtime
buf[136..148].copy_from_slice(b"00000000000\0");
// checksum placeholder
buf[148..156].copy_from_slice(b" ");
// typeflag = '2' (symlink)
buf[156] = b'2';
// linkname
let target = b"/etc/passwd";
buf[157..157 + target.len()].copy_from_slice(target);
// magic
buf[257..263].copy_from_slice(b"ustar\0");
buf[263..265].copy_from_slice(b"00");
// Compute checksum.
let checksum: u32 = buf.iter().map(|&b| b as u32).sum();
let cksum_str = format!("{:06o}\0 ", checksum);
buf[148..156].copy_from_slice(cksum_str.as_bytes());
let mut out = buf;
out.extend(vec![0u8; 1024]); // end-of-archive
out
}
fn build_normal_tar() -> Vec<u8> {
let mut buf = Vec::new();
{
let mut builder = Builder::new(&mut buf);
let content = b"hello";
let mut header = Header::new_gnu();
header.set_entry_type(EntryType::Regular);
header.set_size(content.len() as u64);
header.set_cksum();
builder
.append_data(&mut header, "subdir/hello.txt", content.as_ref())
.unwrap();
builder.finish().unwrap();
}
buf
}
fn build_oversize_tar() -> Vec<u8> {
// Actual 2048-byte body; test will use cap=1024
let mut buf = Vec::new();
{
let mut builder = Builder::new(&mut buf);
let content = vec![0u8; 2048];
let mut header = Header::new_gnu();
header.set_entry_type(EntryType::Regular);
header.set_size(content.len() as u64);
header.set_cksum();
builder
.append_data(&mut header, "bigfile.bin", content.as_slice())
.unwrap();
builder.finish().unwrap();
}
buf
}
#[test]
fn restore_rejects_path_traversal() {
// Craft a tar with "../../escaped.txt" using raw bytes (Builder sanitises paths).
let bytes = raw_tar_with_path(b"../../escaped.txt", b"evil content");
let err = safe_unpack_git_archive(&bytes, 1024 * 1024).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("path traversal") || msg.contains(".."),
"got: {msg}"
);
}
#[test]
fn restore_rejects_absolute_path() {
// Craft a tar with "/etc/escaped.txt" using raw bytes.
let bytes = raw_tar_with_path(b"/etc/escaped.txt", b"evil content");
let err = safe_unpack_git_archive(&bytes, 1024 * 1024).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("path traversal") || msg.contains("absolute"),
"got: {msg}"
);
}
#[test]
fn restore_rejects_symlink() {
let bytes = raw_symlink_tar();
let err = safe_unpack_git_archive(&bytes, 1024 * 1024).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("symlink") || msg.contains("link"),
"got: {msg}"
);
}
#[test]
fn restore_rejects_size_bomb() {
let bytes = build_oversize_tar(); // actual 2048-byte entry
let err = safe_unpack_git_archive(&bytes, 1024).unwrap_err(); // cap = 1024 bytes
let msg = format!("{err:#}");
assert!(
msg.contains("size") || msg.contains("cap") || msg.contains("too large"),
"got: {msg}"
);
}
#[test]
fn restore_accepts_normal_files() {
let buf = build_normal_tar();
let entries = safe_unpack_git_archive(&buf, 1024 * 1024).expect("happy path");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].0, PathBuf::from("subdir/hello.txt"));
assert_eq!(entries[0].1, b"hello");
}