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 { 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 { 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 { 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 { // 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"); }