From 06d2c5c884eee0b5b663730f4d379a7ceae3a8e4 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sat, 1 Aug 2026 05:26:16 +0900 Subject: feat(php-shim): implement Phar/PharData and the zlib/bzip2 functions Adopt the tar, flate2, and bzip2 crates to fill in the phar.rs and compress.rs todos: PharData tar/zip reading, building, and whole-archive compression, plus a native .phar reader that follows the php.net file-format manual and verifies hash-based signatures. Callers now propagate the constructor/extract errors PHP throws, and fwrite accepts byte strings so gzread no longer needs lossy UTF-8. The native .phar writing API stays todo!() (no call sites; Composer's Compiler is not ported) and OPENSSL phar signatures are accepted unverified (TODO(phase-c)). This unblocks Tar::getComposerJson and the tar/phar/gzip downloaders; tar_test (7), artifact_repository_test (2), and phar_archiver_test zip (1) are un-ignored. The archive command itself still panics because ArchiveManager::archive always generates glob excludes whose look-ahead regexes the regex crate cannot compile; converting those patterns to regex-compatible ones is a separate, still-undecided work item. Co-Authored-By: Claude Fable 5 --- crates/shirabe-php-shim/src/phar.rs | 970 ++++++++++++++++++++++++++++++++++-- 1 file changed, 940 insertions(+), 30 deletions(-) (limited to 'crates/shirabe-php-shim/src/phar.rs') diff --git a/crates/shirabe-php-shim/src/phar.rs b/crates/shirabe-php-shim/src/phar.rs index 692d5939..50961bde 100644 --- a/crates/shirabe-php-shim/src/phar.rs +++ b/crates/shirabe-php-shim/src/phar.rs @@ -1,6 +1,408 @@ +//! The native phar binary layout implemented here follows the php.net manual +//! (`https://www.php.net/manual/en/phar.fileformat.php`); no PHP C sources were referenced. + +use crate::UnexpectedValueException; +use std::io::Read as _; +use std::io::Write as _; + +#[derive(Debug, Clone)] +enum PharEntryData { + /// Queued by `build_from_iterator`; the file is read from disk when the archive is written. + Disk(std::path::PathBuf), + /// Loaded from an existing archive. + Memory(Vec), + Dir, +} + +#[derive(Debug, Clone)] +struct PharEntry { + localname: String, + data: PharEntryData, + mode: Option, + mtime: Option, +} + +fn corruption_error(path: &str, detail: &str) -> anyhow::Error { + anyhow::anyhow!(UnexpectedValueException { + message: format!("internal corruption of phar \"{}\" ({})", path, detail), + code: 0, + }) +} + +/// Reads a tar- or zip-based archive (optionally gzip/bzip2 compressed as a whole) +/// into memory. Returns the entries and the detected `Phar::TAR`/`Phar::ZIP` format. +fn read_archive_entries(path: &str) -> anyhow::Result<(Vec, i64)> { + let bytes = std::fs::read(path) + .map_err(|e| corruption_error(path, &format!("unable to open archive: {}", e)))?; + let bytes = if bytes.starts_with(&[0x1f, 0x8b]) { + let mut out = Vec::new(); + flate2::read::MultiGzDecoder::new(&bytes[..]) + .read_to_end(&mut out) + .map_err(|e| corruption_error(path, &format!("invalid gzip data: {}", e)))?; + out + } else if bytes.starts_with(b"BZh") { + let mut out = Vec::new(); + bzip2::read::MultiBzDecoder::new(&bytes[..]) + .read_to_end(&mut out) + .map_err(|e| corruption_error(path, &format!("invalid bzip2 data: {}", e)))?; + out + } else { + bytes + }; + + if bytes.starts_with(b"PK") { + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)) + .map_err(|e| corruption_error(path, &format!("invalid zip data: {}", e)))?; + let mut entries = Vec::new(); + for i in 0..zip.len() { + let mut file = zip + .by_index(i) + .map_err(|e| corruption_error(path, &format!("invalid zip entry: {}", e)))?; + let localname = file.name().trim_end_matches('/').to_string(); + if file.is_dir() { + entries.push(PharEntry { + localname, + data: PharEntryData::Dir, + mode: file.unix_mode().map(|m| m & 0o7777), + mtime: None, + }); + } else { + let mut content = Vec::new(); + file.read_to_end(&mut content) + .map_err(|e| corruption_error(path, &format!("invalid zip entry: {}", e)))?; + entries.push(PharEntry { + localname, + data: PharEntryData::Memory(content), + mode: file.unix_mode().map(|m| m & 0o7777), + mtime: None, + }); + } + } + return Ok((entries, Phar::ZIP)); + } + + let mut archive = tar::Archive::new(std::io::Cursor::new(bytes)); + let mut entries = Vec::new(); + for entry in archive + .entries() + .map_err(|e| corruption_error(path, &format!("invalid tar data: {}", e)))? + { + let mut entry = + entry.map_err(|e| corruption_error(path, &format!("invalid tar entry: {}", e)))?; + let entry_type = entry.header().entry_type(); + // The pax interchange headers (typeflags x and g) are silently ignored, like phar does. + if entry_type.is_pax_global_extensions() || entry_type.is_pax_local_extensions() { + continue; + } + let localname = entry + .path() + .map_err(|e| corruption_error(path, &format!("invalid tar entry name: {}", e)))? + .to_string_lossy() + .trim_end_matches('/') + .to_string(); + let mode = entry.header().mode().ok().map(|m| m & 0o7777); + let mtime = entry.header().mtime().ok(); + if entry_type.is_dir() { + entries.push(PharEntry { + localname, + data: PharEntryData::Dir, + mode, + mtime, + }); + } else { + // Symlink and hardlink entries become empty regular files, matching how + // PharData::extractTo materializes them. + let mut content = Vec::new(); + entry + .read_to_end(&mut content) + .map_err(|e| corruption_error(path, &format!("invalid tar entry: {}", e)))?; + entries.push(PharEntry { + localname, + data: PharEntryData::Memory(content), + mode, + mtime, + }); + } + } + Ok((entries, Phar::TAR)) +} + +fn unix_mtime(time: std::time::SystemTime) -> u64 { + time.duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn extract_entries( + archive_path: &str, + entries: &[PharEntry], + directory: &str, + overwrite: bool, +) -> anyhow::Result<()> { + let extract_error = |detail: String| { + anyhow::anyhow!(PharException { + message: format!( + "Extracting from phar \"{}\" failed: {}", + archive_path, detail + ), + code: 0, + }) + }; + + let base = std::path::Path::new(directory); + std::fs::create_dir_all(base).map_err(|e| extract_error(e.to_string()))?; + for entry in entries { + let rel = std::path::Path::new(&entry.localname); + if rel.is_absolute() + || rel + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { + return Err(extract_error(format!( + "path \"{}\" is invalid", + entry.localname + ))); + } + let dest = base.join(rel); + match &entry.data { + PharEntryData::Dir => { + std::fs::create_dir_all(&dest).map_err(|e| extract_error(e.to_string()))?; + } + PharEntryData::Memory(content) => { + if !overwrite && dest.exists() { + return Err(extract_error(format!( + "\"{}\" already exists", + dest.display() + ))); + } + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent).map_err(|e| extract_error(e.to_string()))?; + } + std::fs::write(&dest, content).map_err(|e| extract_error(e.to_string()))?; + } + PharEntryData::Disk(src) => { + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent).map_err(|e| extract_error(e.to_string()))?; + } + std::fs::copy(src, &dest).map_err(|e| extract_error(e.to_string()))?; + } + } + if let Some(mode) = entry.mode { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(mode & 0o7777)) + .map_err(|e| extract_error(e.to_string()))?; + } + } + Ok(()) +} + +fn crc32(data: &[u8]) -> u32 { + static TABLE: std::sync::OnceLock<[u32; 256]> = std::sync::OnceLock::new(); + let table = TABLE.get_or_init(|| { + let mut table = [0u32; 256]; + for (i, slot) in table.iter_mut().enumerate() { + let mut c = i as u32; + for _ in 0..8 { + c = if c & 1 != 0 { + 0xEDB8_8320 ^ (c >> 1) + } else { + c >> 1 + }; + } + *slot = c; + } + table + }); + let mut crc = 0xFFFF_FFFFu32; + for &byte in data { + crc = table[((crc ^ byte as u32) & 0xFF) as usize] ^ (crc >> 8); + } + !crc +} + +struct ByteReader<'a> { + bytes: &'a [u8], + pos: usize, +} + +impl<'a> ByteReader<'a> { + fn take(&mut self, n: usize) -> Option<&'a [u8]> { + let slice = self.bytes.get(self.pos..self.pos + n)?; + self.pos += n; + Some(slice) + } + + fn u16(&mut self) -> Option { + Some(u16::from_le_bytes(self.take(2)?.try_into().unwrap())) + } + + fn u32(&mut self) -> Option { + Some(u32::from_le_bytes(self.take(4)?.try_into().unwrap())) + } +} + +const PHAR_HAS_SIGNATURE: u32 = 0x0001_0000; +const PHAR_FILE_COMPRESSED_GZ: u32 = 0x0000_1000; +const PHAR_FILE_COMPRESSED_BZ2: u32 = 0x0000_2000; + +fn verify_phar_signature(path: &str, bytes: &[u8]) -> anyhow::Result<()> { + let broken = || corruption_error(path, "phar has a broken or missing signature"); + let n = bytes.len(); + if n < 8 || &bytes[n - 4..] != b"GBMB" { + return Err(broken()); + } + let sig_flags = u32::from_le_bytes(bytes[n - 8..n - 4].try_into().unwrap()); + let algo = match sig_flags { + 0x0001 => "md5", + 0x0002 => "sha1", + 0x0003 => "sha256", + 0x0004 => "sha512", + // TODO(phase-c): OPENSSL phar signatures need an RSA verification decision; they are + // accepted unverified for now. + 0x0010 => return Ok(()), + _ => return Err(broken()), + }; + let sig_len = match algo { + "md5" => 16, + "sha1" => 20, + "sha256" => 32, + _ => 64, + }; + if n < 8 + sig_len { + return Err(broken()); + } + let sig_start = n - 8 - sig_len; + if crate::hash::calculate_hash(algo, &bytes[..sig_start]) != bytes[sig_start..n - 8] { + return Err(corruption_error(path, "phar has a broken signature")); + } + Ok(()) +} + +fn parse_native_phar(path: &str) -> anyhow::Result> { + let bytes = std::fs::read(path) + .map_err(|e| corruption_error(path, &format!("unable to open phar: {}", e)))?; + + let halt = b"__HALT_COMPILER();"; + let halt_pos = bytes + .windows(halt.len()) + .position(|window| window == halt) + .ok_or_else(|| corruption_error(path, "__HALT_COMPILER(); not found in stub"))?; + let mut offset = halt_pos + halt.len(); + for close_tag in [&b" ?>"[..], &b"\n?>"[..]] { + if bytes[offset..].starts_with(close_tag) { + offset += close_tag.len(); + break; + } + } + if bytes[offset..].starts_with(b"\r\n") { + offset += 2; + } else if bytes[offset..].starts_with(b"\n") { + offset += 1; + } + + let truncated = || corruption_error(path, "truncated manifest"); + let mut reader = ByteReader { + bytes: &bytes, + pos: offset, + }; + let manifest_len = reader.u32().ok_or_else(truncated)? as usize; + let contents_offset = reader.pos + manifest_len; + let file_count = reader.u32().ok_or_else(truncated)?; + let _api_version = reader.u16().ok_or_else(truncated)?; + let global_flags = reader.u32().ok_or_else(truncated)?; + let alias_len = reader.u32().ok_or_else(truncated)? as usize; + reader.take(alias_len).ok_or_else(truncated)?; + let metadata_len = reader.u32().ok_or_else(truncated)? as usize; + reader.take(metadata_len).ok_or_else(truncated)?; + + struct RawEntry { + name: String, + size: u32, + timestamp: u32, + compressed_size: u32, + crc: u32, + flags: u32, + } + let mut raw_entries = Vec::new(); + for _ in 0..file_count { + let name_len = reader.u32().ok_or_else(truncated)? as usize; + let name = + String::from_utf8_lossy(reader.take(name_len).ok_or_else(truncated)?).into_owned(); + let size = reader.u32().ok_or_else(truncated)?; + let timestamp = reader.u32().ok_or_else(truncated)?; + let compressed_size = reader.u32().ok_or_else(truncated)?; + let crc = reader.u32().ok_or_else(truncated)?; + let flags = reader.u32().ok_or_else(truncated)?; + let metadata_len = reader.u32().ok_or_else(truncated)? as usize; + reader.take(metadata_len).ok_or_else(truncated)?; + raw_entries.push(RawEntry { + name, + size, + timestamp, + compressed_size, + crc, + flags, + }); + } + + if global_flags & PHAR_HAS_SIGNATURE != 0 { + verify_phar_signature(path, &bytes)?; + } + + let mut entries = Vec::new(); + let mut offset = contents_offset; + for raw in raw_entries { + let end = offset + raw.compressed_size as usize; + let data = bytes + .get(offset..end) + .ok_or_else(|| corruption_error(path, "truncated file contents"))?; + offset = end; + let mode = Some(raw.flags & 0o777); + let mtime = Some(raw.timestamp as u64); + if raw.name.ends_with('/') { + entries.push(PharEntry { + localname: raw.name.trim_end_matches('/').to_string(), + data: PharEntryData::Dir, + mode, + mtime, + }); + continue; + } + let content = if raw.flags & PHAR_FILE_COMPRESSED_GZ != 0 { + let mut out = Vec::new(); + flate2::read::DeflateDecoder::new(data) + .read_to_end(&mut out) + .map_err(|e| corruption_error(path, &format!("invalid deflate data: {}", e)))?; + out + } else if raw.flags & PHAR_FILE_COMPRESSED_BZ2 != 0 { + let mut out = Vec::new(); + bzip2::read::BzDecoder::new(data) + .read_to_end(&mut out) + .map_err(|e| corruption_error(path, &format!("invalid bzip2 data: {}", e)))?; + out + } else { + data.to_vec() + }; + if content.len() != raw.size as usize { + return Err(corruption_error(path, "file size mismatch")); + } + if crc32(&content) != raw.crc { + return Err(corruption_error(path, "crc32 mismatch")); + } + entries.push(PharEntry { + localname: raw.name, + data: PharEntryData::Memory(content), + mode, + mtime, + }); + } + Ok(entries) +} + #[derive(Debug)] pub struct Phar { path: String, + entries: Vec, } impl Phar { @@ -9,15 +411,23 @@ impl Phar { pub const GZ: i64 = 4096; pub const BZ2: i64 = 8192; - pub fn new(_a: String) -> Self { - todo!() + pub fn new(path: String) -> anyhow::Result { + let entries = parse_native_phar(&path)?; + Ok(Self { path, entries }) } - pub fn extract_to(&self, _a: &str, _b: Option<()>, _c: bool) { - todo!() + pub fn extract_to( + &self, + directory: &str, + _files: Option<()>, + overwrite: bool, + ) -> anyhow::Result<()> { + extract_entries(&self.path, &self.entries, directory, overwrite) } pub fn running(_return_full: bool) -> String { + // TODO(phase-c): reports the phar the current script runs from; Shirabe is a native + // binary and nothing in the ported code calls this yet. todo!() } } @@ -25,6 +435,9 @@ impl Phar { impl Phar { pub const SHA512: i64 = 16; + // TODO(phase-c): the native .phar writing API below has no call sites in the ported code + // (Composer's Compiler is not ported); it is deliberately deferred. + pub fn new_phar(_filename: String, _flags: i64, _alias: &str) -> Self { todo!() } @@ -65,70 +478,567 @@ impl std::fmt::Display for PharException { impl std::error::Error for PharException {} #[derive(Debug)] -pub struct PharFileInfo; +pub struct PharFileInfo { + file_name: String, + is_dir: bool, + content: Vec, +} impl PharFileInfo { - pub fn get_content(&self) -> String { - todo!() + pub fn get_content(&self) -> Vec { + self.content.clone() } pub fn get_basename(&self) -> String { - todo!() + self.file_name + .rsplit('/') + .next() + .unwrap_or(&self.file_name) + .to_string() } pub fn is_dir(&self) -> bool { - todo!() + self.is_dir } } #[derive(Debug)] pub struct PharData { path: String, + format: i64, + entries: std::cell::RefCell>, } impl PharData { - pub fn new(_a: String) -> Self { - todo!() + pub fn new(path: String) -> anyhow::Result { + Self::open(path, None) } - pub fn new_with_format(_path: String, _flags: i64, _alias: &str, _format: i64) -> Self { - todo!() + pub fn new_with_format( + path: String, + _flags: i64, + _alias: &str, + format: i64, + ) -> anyhow::Result { + Self::open(path, Some(format)) } - pub fn can_compress(_algo: i64) -> bool { - todo!() + fn open(path: String, format: Option) -> anyhow::Result { + if crate::file_exists(&path) { + let (entries, detected_format) = read_archive_entries(&path)?; + return Ok(Self { + path, + format: detected_format, + entries: std::cell::RefCell::new(entries), + }); + } + let parent_exists = match std::path::Path::new(&path).parent() { + Some(parent) if parent.as_os_str().is_empty() => true, + Some(parent) => parent.is_dir(), + None => false, + }; + if !parent_exists { + return Err(anyhow::anyhow!(UnexpectedValueException { + message: format!( + "Cannot create phar '{}', file extension (or combination) not recognised or the directory does not exist", + path + ), + code: 0, + })); + } + let format = format.unwrap_or(if path.ends_with(".zip") { + Phar::ZIP + } else { + Phar::TAR + }); + Ok(Self { + path, + format, + entries: std::cell::RefCell::new(Vec::new()), + }) + } + + pub fn can_compress(algo: i64) -> bool { + matches!(algo, Phar::GZ | Phar::BZ2) } pub fn valid(&self) -> bool { - todo!() + !self.entries.borrow().is_empty() } - pub fn get(&self, _key: &str) -> Option { - todo!() + pub fn get(&self, key: &str) -> Option { + let entries = self.entries.borrow(); + let key = key.trim_end_matches('/'); + for entry in entries.iter() { + if entry.localname == key { + return Some(PharFileInfo { + file_name: entry.localname.clone(), + is_dir: matches!(entry.data, PharEntryData::Dir), + content: match &entry.data { + PharEntryData::Memory(content) => content.clone(), + _ => Vec::new(), + }, + }); + } + } + let prefix = format!("{}/", key); + if entries.iter().any(|e| e.localname.starts_with(&prefix)) { + return Some(PharFileInfo { + file_name: key.to_string(), + is_dir: true, + content: Vec::new(), + }); + } + None } + /// Iterates the top level of the archive (files, plus explicit and implicit + /// directories) in sorted order, like a `DirectoryIterator` over the phar root. pub fn iter(&self) -> impl Iterator { - todo!(); - std::iter::empty() + let entries = self.entries.borrow(); + let mut top_level: indexmap::IndexMap)> = indexmap::IndexMap::new(); + for entry in entries.iter() { + match entry.localname.split_once('/') { + Some((first_segment, _)) => { + top_level + .entry(first_segment.to_string()) + .or_insert((true, Vec::new())); + } + None => { + top_level.insert( + entry.localname.clone(), + ( + matches!(entry.data, PharEntryData::Dir), + match &entry.data { + PharEntryData::Memory(content) => content.clone(), + _ => Vec::new(), + }, + ), + ); + } + } + } + let mut items: Vec = top_level + .into_iter() + .map(|(file_name, (is_dir, content))| PharFileInfo { + file_name, + is_dir, + content, + }) + .collect(); + items.sort_by(|a, b| a.file_name.cmp(&b.file_name)); + items.into_iter() } - pub fn extract_to(&self, _a: &str, _b: Option<()>, _c: bool) { - todo!() + pub fn extract_to( + &self, + directory: &str, + _files: Option<()>, + overwrite: bool, + ) -> anyhow::Result<()> { + extract_entries(&self.path, &self.entries.borrow(), directory, overwrite) } - pub fn add_empty_dir(&self, _a: &str) { - todo!() + pub fn add_empty_dir(&self, dirname: &str) -> anyhow::Result<()> { + let localname = dirname.trim_matches('/').to_string(); + { + let mut entries = self.entries.borrow_mut(); + if !entries.iter().any(|e| e.localname == localname) { + entries.push(PharEntry { + localname, + data: PharEntryData::Dir, + mode: Some(0o777), + mtime: None, + }); + } + } + self.flush() } pub fn build_from_iterator( &self, - _iter: &mut dyn Iterator, - _base: &str, - ) { - todo!() + iter: &mut dyn Iterator, + base_directory: &str, + ) -> anyhow::Result<()> { + { + let mut entries = self.entries.borrow_mut(); + for file in iter { + let localname = file + .strip_prefix(base_directory) + .map_err(|_| { + anyhow::anyhow!(UnexpectedValueException { + message: format!( + "Iterator returned a path \"{}\" that is not in the base directory \"{}\"", + file.display(), + base_directory + ), + code: 0, + }) + })? + .to_string_lossy() + .into_owned(); + entries.push(PharEntry { + localname, + data: PharEntryData::Disk(file), + mode: None, + mtime: None, + }); + } + } + self.flush() } - pub fn compress(&self, _algo: i64) { - todo!() + /// Compresses the entire tar archive into a sibling file with the added + /// `.gz`/`.bz2` extension; the uncompressed archive is left in place. + pub fn compress(&self, algo: i64) -> anyhow::Result<()> { + assert_eq!( + self.format, + Phar::TAR, + "PharData::compress: only tar-based archives can be compressed as a whole" + ); + let tar_bytes = self.build_tar_bytes()?; + let write_error = |e: std::io::Error| { + anyhow::anyhow!(PharException { + message: format!("Unable to compress phar archive \"{}\": {}", self.path, e), + code: 0, + }) + }; + let (target, compressed) = match algo { + Phar::GZ => { + let mut encoder = + flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(&tar_bytes).map_err(write_error)?; + ( + format!("{}.gz", self.path), + encoder.finish().map_err(write_error)?, + ) + } + Phar::BZ2 => { + let mut encoder = + bzip2::write::BzEncoder::new(Vec::new(), bzip2::Compression::new(4)); + encoder.write_all(&tar_bytes).map_err(write_error)?; + ( + format!("{}.bz2", self.path), + encoder.finish().map_err(write_error)?, + ) + } + _ => panic!("PharData::compress: unsupported compression algorithm {algo}"), + }; + std::fs::write(target, compressed).map_err(write_error)?; + Ok(()) + } + + /// Writes the in-memory entries out to `self.path`. Like phar, nothing is + /// written as long as the archive holds no entries. + fn flush(&self) -> anyhow::Result<()> { + if self.entries.borrow().is_empty() { + return Ok(()); + } + if self.format == Phar::ZIP { + return self.write_zip(); + } + let bytes = self.build_tar_bytes()?; + std::fs::write(&self.path, bytes).map_err(|e| { + anyhow::anyhow!(PharException { + message: format!("Unable to write phar archive \"{}\": {}", self.path, e), + code: 0, + }) + }) + } + + fn build_tar_bytes(&self) -> anyhow::Result> { + let write_error = |e: std::io::Error| { + anyhow::anyhow!(PharException { + message: format!("Unable to write phar archive \"{}\": {}", self.path, e), + code: 0, + }) + }; + let mut builder = tar::Builder::new(Vec::new()); + for entry in self.entries.borrow().iter() { + let mut header = tar::Header::new_ustar(); + header.set_uid(0); + header.set_gid(0); + match &entry.data { + PharEntryData::Disk(source) => { + use std::os::unix::fs::PermissionsExt; + let metadata = std::fs::metadata(source).map_err(write_error)?; + header.set_entry_type(tar::EntryType::Regular); + header.set_size(metadata.len()); + header.set_mode(metadata.permissions().mode() & 0o7777); + header.set_mtime( + metadata + .modified() + .map(unix_mtime) + .unwrap_or_else(|_| unix_mtime(std::time::SystemTime::now())), + ); + let file = std::fs::File::open(source).map_err(write_error)?; + builder + .append_data(&mut header, &entry.localname, file) + .map_err(write_error)?; + } + PharEntryData::Memory(content) => { + header.set_entry_type(tar::EntryType::Regular); + header.set_size(content.len() as u64); + header.set_mode(entry.mode.unwrap_or(0o644)); + header.set_mtime( + entry + .mtime + .unwrap_or_else(|| unix_mtime(std::time::SystemTime::now())), + ); + builder + .append_data(&mut header, &entry.localname, &content[..]) + .map_err(write_error)?; + } + PharEntryData::Dir => { + header.set_entry_type(tar::EntryType::Directory); + header.set_size(0); + header.set_mode(entry.mode.unwrap_or(0o777)); + header.set_mtime( + entry + .mtime + .unwrap_or_else(|| unix_mtime(std::time::SystemTime::now())), + ); + builder + .append_data(&mut header, &entry.localname, std::io::empty()) + .map_err(write_error)?; + } + } + } + builder.into_inner().map_err(write_error) + } + + fn write_zip(&self) -> anyhow::Result<()> { + let write_error = |e: String| { + anyhow::anyhow!(PharException { + message: format!("Unable to write phar archive \"{}\": {}", self.path, e), + code: 0, + }) + }; + let file = std::fs::File::create(&self.path).map_err(|e| write_error(e.to_string()))?; + let mut writer = zip::ZipWriter::new(file); + for entry in self.entries.borrow().iter() { + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + match &entry.data { + PharEntryData::Disk(source) => { + use std::os::unix::fs::PermissionsExt; + let metadata = + std::fs::metadata(source).map_err(|e| write_error(e.to_string()))?; + writer + .start_file( + entry.localname.as_str(), + options.unix_permissions(metadata.permissions().mode() & 0o7777), + ) + .map_err(|e| write_error(e.to_string()))?; + let mut file = + std::fs::File::open(source).map_err(|e| write_error(e.to_string()))?; + std::io::copy(&mut file, &mut writer) + .map_err(|e| write_error(e.to_string()))?; + } + PharEntryData::Memory(content) => { + let options = match entry.mode { + Some(mode) => options.unix_permissions(mode), + None => options, + }; + writer + .start_file(entry.localname.as_str(), options) + .map_err(|e| write_error(e.to_string()))?; + writer + .write_all(content) + .map_err(|e| write_error(e.to_string()))?; + } + PharEntryData::Dir => { + writer + .add_directory(entry.localname.as_str(), options) + .map_err(|e| write_error(e.to_string()))?; + } + } + } + writer.finish().map_err(|e| write_error(e.to_string()))?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write_file(dir: &std::path::Path, name: &str, content: &[u8]) -> std::path::PathBuf { + let path = dir.join(name); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, content).unwrap(); + path + } + + #[test] + fn phar_data_tar_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("src"); + let a = write_file(&src, "a.txt", b"hello"); + let b = write_file(&src, "sub/b.txt", b"world"); + let tar_path = dir.path().join("out.tar"); + + let phar = PharData::new(tar_path.to_string_lossy().into_owned()).unwrap(); + assert!(!phar.valid()); + phar.build_from_iterator(&mut vec![a, b].into_iter(), src.to_str().unwrap()) + .unwrap(); + phar.add_empty_dir("emptydir").unwrap(); + assert!(tar_path.exists()); + + let read_back = PharData::new(tar_path.to_string_lossy().into_owned()).unwrap(); + assert!(read_back.valid()); + assert_eq!(read_back.get("a.txt").unwrap().get_content(), b"hello"); + assert_eq!(read_back.get("sub/b.txt").unwrap().get_content(), b"world"); + assert!(read_back.get("sub").unwrap().is_dir()); + assert!(read_back.get("missing").is_none()); + let top: Vec<(String, bool)> = read_back + .iter() + .map(|info| (info.get_basename(), info.is_dir())) + .collect(); + assert_eq!( + top, + vec![ + ("a.txt".to_string(), false), + ("emptydir".to_string(), true), + ("sub".to_string(), true), + ] + ); + + let out = dir.path().join("extracted"); + read_back + .extract_to(out.to_str().unwrap(), None, true) + .unwrap(); + assert_eq!(std::fs::read(out.join("a.txt")).unwrap(), b"hello"); + assert_eq!(std::fs::read(out.join("sub/b.txt")).unwrap(), b"world"); + assert!(out.join("emptydir").is_dir()); + } + + #[test] + fn phar_data_compress_creates_sibling_archives() { + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("src"); + let a = write_file(&src, "a.txt", b"hello"); + let tar_path = dir.path().join("out.tar"); + + let phar = PharData::new(tar_path.to_string_lossy().into_owned()).unwrap(); + phar.build_from_iterator(&mut vec![a].into_iter(), src.to_str().unwrap()) + .unwrap(); + phar.compress(Phar::GZ).unwrap(); + phar.compress(Phar::BZ2).unwrap(); + + assert!(tar_path.exists()); + for compressed in ["out.tar.gz", "out.tar.bz2"] { + let read_back = + PharData::new(dir.path().join(compressed).to_string_lossy().into_owned()).unwrap(); + assert_eq!(read_back.get("a.txt").unwrap().get_content(), b"hello"); + } + } + + #[test] + fn phar_data_missing_file_in_missing_directory_is_rejected() { + let error = PharData::new("/nonexistent-dir/foo.tar".to_string()).unwrap_err(); + assert!( + error + .downcast_ref::() + .unwrap() + .message + .starts_with("Cannot create phar") + ); + } + + /// Builds a minimal native phar (one stored file, one deflated file, SHA-1 + /// signature) following the php.net manual layout. + fn build_native_phar(tampered: bool) -> Vec { + let stored = (b"Hello World".to_vec(), "dir/hello.txt"); + let big = "abc".repeat(1000).into_bytes(); + let mut deflated = Vec::new(); + { + let mut encoder = + flate2::write::DeflateEncoder::new(&mut deflated, flate2::Compression::default()); + encoder.write_all(&big).unwrap(); + encoder.finish().unwrap(); + } + + let mut manifest = Vec::new(); + manifest.extend_from_slice(&2u32.to_le_bytes()); + manifest.extend_from_slice(&[0x11, 0x10]); + manifest.extend_from_slice(&(PHAR_HAS_SIGNATURE | 0x1000).to_le_bytes()); + manifest.extend_from_slice(&0u32.to_le_bytes()); + manifest.extend_from_slice(&0u32.to_le_bytes()); + for (name, size, csize, crc, flags) in [ + ( + stored.1, + stored.0.len(), + stored.0.len(), + crc32(&stored.0), + 0o644u32, + ), + ( + "big.txt", + big.len(), + deflated.len(), + crc32(&big), + 0o644 | PHAR_FILE_COMPRESSED_GZ, + ), + ] { + manifest.extend_from_slice(&(name.len() as u32).to_le_bytes()); + manifest.extend_from_slice(name.as_bytes()); + manifest.extend_from_slice(&(size as u32).to_le_bytes()); + manifest.extend_from_slice(&0u32.to_le_bytes()); + manifest.extend_from_slice(&(csize as u32).to_le_bytes()); + manifest.extend_from_slice(&crc.to_le_bytes()); + manifest.extend_from_slice(&flags.to_le_bytes()); + manifest.extend_from_slice(&0u32.to_le_bytes()); + } + + let mut bytes = b"\r\n".to_vec(); + bytes.extend_from_slice(&(manifest.len() as u32).to_le_bytes()); + bytes.extend_from_slice(&manifest); + bytes.extend_from_slice(&stored.0); + bytes.extend_from_slice(&deflated); + let signature = crate::hash::calculate_hash("sha1", &bytes); + if tampered { + let content_start = bytes.len() - stored.0.len() - deflated.len(); + bytes[content_start] ^= 0xFF; + } + bytes.extend_from_slice(&signature); + bytes.extend_from_slice(&2u32.to_le_bytes()); + bytes.extend_from_slice(b"GBMB"); + bytes + } + + #[test] + fn phar_native_read_and_extract() { + let dir = tempfile::tempdir().unwrap(); + let phar_path = dir.path().join("test.phar"); + std::fs::write(&phar_path, build_native_phar(false)).unwrap(); + + let phar = Phar::new(phar_path.to_string_lossy().into_owned()).unwrap(); + let out = dir.path().join("extracted"); + phar.extract_to(out.to_str().unwrap(), None, true).unwrap(); + assert_eq!( + std::fs::read(out.join("dir/hello.txt")).unwrap(), + b"Hello World" + ); + assert_eq!( + std::fs::read(out.join("big.txt")).unwrap(), + "abc".repeat(1000).into_bytes() + ); + } + + #[test] + fn phar_native_broken_signature_is_rejected() { + let dir = tempfile::tempdir().unwrap(); + let phar_path = dir.path().join("tampered.phar"); + std::fs::write(&phar_path, build_native_phar(true)).unwrap(); + + let error = Phar::new(phar_path.to_string_lossy().into_owned()).unwrap_err(); + assert!( + error + .downcast_ref::() + .unwrap() + .message + .contains("broken signature") + ); } } -- cgit v1.3.1