aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/shirabe-php-shim/src/phar.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/shirabe-php-shim/src/phar.rs')
-rw-r--r--crates/shirabe-php-shim/src/phar.rs970
1 files changed, 940 insertions, 30 deletions
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<u8>),
+ Dir,
+}
+
+#[derive(Debug, Clone)]
+struct PharEntry {
+ localname: String,
+ data: PharEntryData,
+ mode: Option<u32>,
+ mtime: Option<u64>,
+}
+
+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<PharEntry>, 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<u16> {
+ Some(u16::from_le_bytes(self.take(2)?.try_into().unwrap()))
+ }
+
+ fn u32(&mut self) -> Option<u32> {
+ 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<Vec<PharEntry>> {
+ 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<PharEntry>,
}
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<Self> {
+ 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<u8>,
+}
impl PharFileInfo {
- pub fn get_content(&self) -> String {
- todo!()
+ pub fn get_content(&self) -> Vec<u8> {
+ 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<Vec<PharEntry>>,
}
impl PharData {
- pub fn new(_a: String) -> Self {
- todo!()
+ pub fn new(path: String) -> anyhow::Result<Self> {
+ 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> {
+ Self::open(path, Some(format))
}
- pub fn can_compress(_algo: i64) -> bool {
- todo!()
+ fn open(path: String, format: Option<i64>) -> anyhow::Result<Self> {
+ 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<PharFileInfo> {
- todo!()
+ pub fn get(&self, key: &str) -> Option<PharFileInfo> {
+ 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<Item = PharFileInfo> {
- todo!();
- std::iter::empty()
+ let entries = self.entries.borrow();
+ let mut top_level: indexmap::IndexMap<String, (bool, Vec<u8>)> = 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<PharFileInfo> = 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<Item = std::path::PathBuf>,
- _base: &str,
- ) {
- todo!()
+ iter: &mut dyn Iterator<Item = std::path::PathBuf>,
+ 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<Vec<u8>> {
+ 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::<UnexpectedValueException>()
+ .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<u8> {
+ 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"<?php __HALT_COMPILER(); ?>\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::<UnexpectedValueException>()
+ .unwrap()
+ .message
+ .contains("broken signature")
+ );
}
}