aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-08-01 05:26:16 +0900
committernsfisis <nsfisis@gmail.com>2026-08-01 18:41:11 +0900
commit06d2c5c884eee0b5b663730f4d379a7ceae3a8e4 (patch)
tree5cd768e0bdad3f8a056dd9b047de363963315135
parentb8d46b0495d00815a699932ced0b43955c949ab9 (diff)
downloadphp-shirabe-06d2c5c884eee0b5b663730f4d379a7ceae3a8e4.tar.gz
php-shirabe-06d2c5c884eee0b5b663730f4d379a7ceae3a8e4.tar.zst
php-shirabe-06d2c5c884eee0b5b663730f4d379a7ceae3a8e4.zip
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 <noreply@anthropic.com>
-rw-r--r--Cargo.lock35
-rw-r--r--Cargo.toml3
-rw-r--r--crates/shirabe-php-shim/Cargo.toml3
-rw-r--r--crates/shirabe-php-shim/src/compress.rs74
-rw-r--r--crates/shirabe-php-shim/src/fs.rs4
-rw-r--r--crates/shirabe-php-shim/src/hash.rs3
-rw-r--r--crates/shirabe-php-shim/src/phar.rs970
-rw-r--r--crates/shirabe/src/downloader/gzip_downloader.rs5
-rw-r--r--crates/shirabe/src/downloader/phar_downloader.rs4
-rw-r--r--crates/shirabe/src/downloader/tar_downloader.rs4
-rw-r--r--crates/shirabe/src/io/buffer_io.rs2
-rw-r--r--crates/shirabe/src/package/archiver/archivable_files_filter.rs5
-rw-r--r--crates/shirabe/src/package/archiver/phar_archiver.rs8
-rw-r--r--crates/shirabe/src/util/error_handler.rs2
-rw-r--r--crates/shirabe/src/util/perforce.rs30
-rw-r--r--crates/shirabe/src/util/remote_filesystem.rs6
-rw-r--r--crates/shirabe/src/util/tar.rs17
-rw-r--r--crates/shirabe/tests/package/archiver/archive_manager_test.rs8
-rw-r--r--crates/shirabe/tests/package/archiver/phar_archiver_test.rs6
-rw-r--r--crates/shirabe/tests/repository/artifact_repository_test.rs4
-rw-r--r--crates/shirabe/tests/util/tar_test.rs7
21 files changed, 1100 insertions, 100 deletions
diff --git a/Cargo.lock b/Cargo.lock
index df724be2..6e800afd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -484,6 +484,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
+name = "filetime"
+version = "0.2.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
+dependencies = [
+ "cfg-if",
+ "libc",
+]
+
+[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -495,6 +505,7 @@ version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
+ "crc32fast",
"miniz_oxide",
"zlib-rs",
]
@@ -2133,8 +2144,10 @@ name = "shirabe-php-shim"
version = "0.0.1"
dependencies = [
"anyhow",
+ "bzip2",
"chrono",
"fastrand",
+ "flate2",
"indexmap",
"md5",
"regex",
@@ -2146,6 +2159,7 @@ dependencies = [
"sha1",
"sha2 0.11.0",
"shirabe-php-src",
+ "tar",
"tempfile",
"twox-hash",
"zip",
@@ -2298,6 +2312,17 @@ dependencies = [
]
[[package]]
+name = "tar"
+version = "0.4.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
+dependencies = [
+ "filetime",
+ "libc",
+ "xattr",
+]
+
+[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3141,6 +3166,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
+name = "xattr"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
+dependencies = [
+ "libc",
+ "rustix",
+]
+
+[[package]]
name = "yoke"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
index 396ae913..3fc7c926 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -19,8 +19,10 @@ shirabe-spdx-licenses = { path = "crates/shirabe-spdx-licenses" }
anyhow = "1.0.102"
async-trait = "0.1.89"
base64 = "0.22.1"
+bzip2 = "0.6.1"
chrono = { version = "0.4.44", features = ["serde"] }
fastrand = "2.4.1"
+flate2 = "1.1.9"
futures = "0.3.32"
indexmap = { version = "2.14.0", features = ["serde"] }
jsonschema = { version = "0.46.6", default-features = false }
@@ -35,6 +37,7 @@ serde_urlencoded = "0.7.1"
serial_test = "3.5.0"
sha1 = "0.11.0"
sha2 = "0.11.0"
+tar = "0.4.46"
tempfile = "3.27.0"
tokio = { version = "1.52.3", features = ["full"] }
tracing = "0.1.44"
diff --git a/crates/shirabe-php-shim/Cargo.toml b/crates/shirabe-php-shim/Cargo.toml
index a0e0ab3b..2b9b392a 100644
--- a/crates/shirabe-php-shim/Cargo.toml
+++ b/crates/shirabe-php-shim/Cargo.toml
@@ -6,8 +6,10 @@ edition.workspace = true
[dependencies]
shirabe-php-src.workspace = true
anyhow.workspace = true
+bzip2.workspace = true
chrono.workspace = true
fastrand.workspace = true
+flate2.workspace = true
indexmap.workspace = true
md5.workspace = true
regex.workspace = true
@@ -18,6 +20,7 @@ serde_json.workspace = true
serde_urlencoded.workspace = true
sha1.workspace = true
sha2.workspace = true
+tar.workspace = true
twox-hash.workspace = true
zip.workspace = true
diff --git a/crates/shirabe-php-shim/src/compress.rs b/crates/shirabe-php-shim/src/compress.rs
index 68230007..d62be7c6 100644
--- a/crates/shirabe-php-shim/src/compress.rs
+++ b/crates/shirabe-php-shim/src/compress.rs
@@ -1,25 +1,73 @@
-use crate::PhpMixed;
+use std::io::Read as _;
+use std::io::Write as _;
-pub fn gzopen(_file: &str, _mode: &str) -> PhpMixed {
- todo!()
+/// Handle returned by `gzopen()`. PHP models this as a zlib stream resource;
+/// clones share the same underlying stream, like PHP resource copies do.
+/// Only the read modes used by Composer are supported.
+#[derive(Debug, Clone)]
+pub struct GzFile(std::rc::Rc<std::cell::RefCell<flate2::read::MultiGzDecoder<std::fs::File>>>);
+
+pub fn gzopen(file: &str, mode: &str) -> Result<GzFile, std::io::Error> {
+ assert!(
+ mode.starts_with('r'),
+ "gzopen: only read modes are supported (got {mode:?})"
+ );
+ let f = std::fs::File::open(file)?;
+ Ok(GzFile(std::rc::Rc::new(std::cell::RefCell::new(
+ flate2::read::MultiGzDecoder::new(f),
+ ))))
}
-pub fn gzread(_file: PhpMixed, _length: i64) -> String {
- todo!()
+/// PHP `gzread()` returns up to `length` bytes of decompressed data, or `false` on
+/// error; the `false` case maps to an empty buffer, which stops the caller's read
+/// loop just as PHP's falsy check does.
+pub fn gzread(file: GzFile, length: i64) -> Vec<u8> {
+ let mut decoder = file.0.borrow_mut();
+ let mut buf = vec![0u8; length.max(0) as usize];
+ let mut filled = 0;
+ while filled < buf.len() {
+ match decoder.read(&mut buf[filled..]) {
+ Ok(0) => break,
+ Ok(n) => filled += n,
+ Err(_) => {
+ filled = 0;
+ break;
+ }
+ }
+ }
+ buf.truncate(filled);
+ buf
}
-pub fn gzclose(_file: PhpMixed) {
- todo!()
+pub fn gzclose(file: GzFile) {
+ drop(file);
}
-pub fn gzcompress(_data: &[u8]) -> Option<Vec<u8>> {
- todo!()
+/// PHP `gzcompress()` with the default level (-1 = zlib default) and ZLIB encoding.
+pub fn gzcompress(data: &[u8]) -> Option<Vec<u8>> {
+ let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
+ encoder.write_all(data).ok()?;
+ encoder.finish().ok()
}
-pub fn bzcompress(_data: &[u8]) -> Option<Vec<u8>> {
- todo!()
+/// PHP `bzcompress()` with the default block size (4).
+pub fn bzcompress(data: &[u8]) -> Option<Vec<u8>> {
+ let mut encoder = bzip2::write::BzEncoder::new(Vec::new(), bzip2::Compression::new(4));
+ encoder.write_all(data).ok()?;
+ encoder.finish().ok()
}
-pub fn zlib_decode(_data: &str) -> Option<String> {
- todo!()
+/// PHP `zlib_decode()` detects the encoding (gzip or zlib) from the data itself.
+pub fn zlib_decode(data: &[u8]) -> Option<Vec<u8>> {
+ let mut decoded = Vec::new();
+ if data.starts_with(&[0x1f, 0x8b]) {
+ flate2::read::MultiGzDecoder::new(data)
+ .read_to_end(&mut decoded)
+ .ok()?;
+ } else {
+ flate2::read::ZlibDecoder::new(data)
+ .read_to_end(&mut decoded)
+ .ok()?;
+ }
+ Some(decoded)
}
diff --git a/crates/shirabe-php-shim/src/fs.rs b/crates/shirabe-php-shim/src/fs.rs
index a20e956f..d15751dd 100644
--- a/crates/shirabe-php-shim/src/fs.rs
+++ b/crates/shirabe-php-shim/src/fs.rs
@@ -329,8 +329,8 @@ pub fn fopen(file: &str, mode: &str) -> Result<PhpResource, std::io::Error> {
/// PHP `fwrite()`. `length` caps the number of bytes written (`None` = whole string).
/// Returns the byte count written, or `None` for PHP's `false`-on-failure.
-pub fn fwrite(stream: &PhpResource, data: &str, length: Option<i64>) -> Option<i64> {
- let bytes = data.as_bytes();
+pub fn fwrite(stream: &PhpResource, data: impl AsRef<[u8]>, length: Option<i64>) -> Option<i64> {
+ let bytes = data.as_ref();
let bytes = match length {
Some(l) if l >= 0 => &bytes[..(l as usize).min(bytes.len())],
_ => bytes,
diff --git a/crates/shirabe-php-shim/src/hash.rs b/crates/shirabe-php-shim/src/hash.rs
index d77cc0a7..d98dcb22 100644
--- a/crates/shirabe-php-shim/src/hash.rs
+++ b/crates/shirabe-php-shim/src/hash.rs
@@ -14,11 +14,12 @@ pub fn hash_file(algo: &str, filename: impl AsRef<std::path::Path>) -> Option<St
Some(bin2hex(&calculate_hash(algo, &data)))
}
-fn calculate_hash(algo: &str, data: &[u8]) -> Vec<u8> {
+pub(crate) fn calculate_hash(algo: &str, data: &[u8]) -> Vec<u8> {
match algo {
"md5" => md5::compute(data).0.to_vec(),
"sha1" => sha1::Sha1::digest(data).to_vec(),
"sha256" => sha2::Sha256::digest(data).to_vec(),
+ "sha512" => sha2::Sha512::digest(data).to_vec(),
"xxh3" => twox_hash::XxHash3_64::oneshot(data).to_be_bytes().to_vec(),
_ => panic!("unsupported hash algorithm: {}", algo),
}
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")
+ );
}
}
diff --git a/crates/shirabe/src/downloader/gzip_downloader.rs b/crates/shirabe/src/downloader/gzip_downloader.rs
index 4ea8f3b9..6b5f3872 100644
--- a/crates/shirabe/src/downloader/gzip_downloader.rs
+++ b/crates/shirabe/src/downloader/gzip_downloader.rs
@@ -50,14 +50,15 @@ impl GzipDownloader {
}
fn extract_using_ext(&self, file: &str, target_filepath: &str) {
- let archive_file = gzopen(file, "rb");
+ // PHP does not check the gzopen result either; gzread(false, ...) is a fatal TypeError.
+ let archive_file = gzopen(file, "rb").unwrap();
let target_file = fopen(target_filepath, "wb").unwrap();
loop {
let string = gzread(archive_file.clone(), 4096);
if string.is_empty() {
break;
}
- fwrite(&target_file, &string, Some(Platform::strlen(&string)));
+ fwrite(&target_file, &string, Some(string.len() as i64));
}
gzclose(archive_file);
fclose(&target_file);
diff --git a/crates/shirabe/src/downloader/phar_downloader.rs b/crates/shirabe/src/downloader/phar_downloader.rs
index cbbb8252..b33fa74a 100644
--- a/crates/shirabe/src/downloader/phar_downloader.rs
+++ b/crates/shirabe/src/downloader/phar_downloader.rs
@@ -62,8 +62,8 @@ impl ArchiveDownloader for PharDownloader {
path: &str,
) -> anyhow::Result<Option<PhpMixed>> {
// Can throw an UnexpectedValueException
- let archive = Phar::new(file.to_string());
- archive.extract_to(path, None, true);
+ let archive = Phar::new(file.to_string())?;
+ archive.extract_to(path, None, true)?;
// TODO: handle openssl signed phars
// https://github.com/composer/composer/pull/33#issuecomment-2250768
// https://github.com/koto/phar-util
diff --git a/crates/shirabe/src/downloader/tar_downloader.rs b/crates/shirabe/src/downloader/tar_downloader.rs
index dfb825e4..ae600971 100644
--- a/crates/shirabe/src/downloader/tar_downloader.rs
+++ b/crates/shirabe/src/downloader/tar_downloader.rs
@@ -61,8 +61,8 @@ impl ArchiveDownloader for TarDownloader {
file: &str,
path: &str,
) -> anyhow::Result<Option<PhpMixed>> {
- let archive = PharData::new(file.to_string());
- archive.extract_to(path, None, true);
+ let archive = PharData::new(file.to_string())?;
+ archive.extract_to(path, None, true)?;
Ok(None)
}
diff --git a/crates/shirabe/src/io/buffer_io.rs b/crates/shirabe/src/io/buffer_io.rs
index f00ebe25..491cd316 100644
--- a/crates/shirabe/src/io/buffer_io.rs
+++ b/crates/shirabe/src/io/buffer_io.rs
@@ -142,7 +142,7 @@ impl BufferIO {
};
for input in inputs {
- fwrite(&stream, &format!("{}{}", input, PHP_EOL), None);
+ fwrite(&stream, format!("{}{}", input, PHP_EOL), None);
}
rewind(&stream);
diff --git a/crates/shirabe/src/package/archiver/archivable_files_filter.rs b/crates/shirabe/src/package/archiver/archivable_files_filter.rs
index ee39ba37..02a622f7 100644
--- a/crates/shirabe/src/package/archiver/archivable_files_filter.rs
+++ b/crates/shirabe/src/package/archiver/archivable_files_filter.rs
@@ -24,11 +24,12 @@ impl ArchivableFilesFilter {
true
}
- pub fn add_empty_dir(&self, phar: &PharData, sources: &str) {
+ pub fn add_empty_dir(&self, phar: &PharData, sources: &str) -> anyhow::Result<()> {
for filepath in &self.dirs {
let localname = filepath.replace(&format!("{}/", sources), "");
- phar.add_empty_dir(&localname);
+ phar.add_empty_dir(&localname)?;
}
+ Ok(())
}
}
diff --git a/crates/shirabe/src/package/archiver/phar_archiver.rs b/crates/shirabe/src/package/archiver/phar_archiver.rs
index 1bac060b..55e4cb95 100644
--- a/crates/shirabe/src/package/archiver/phar_archiver.rs
+++ b/crates/shirabe/src/package/archiver/phar_archiver.rs
@@ -73,11 +73,11 @@ impl ArchiverInterface for PharArchiver {
FilesystemIterator::KEY_AS_PATHNAME | FilesystemIterator::CURRENT_AS_FILEINFO,
"",
*formats.get(format.as_str()).unwrap_or(&Phar::TAR),
- );
+ )?;
let files = ArchivableFilesFinder::new(&sources, excludes, ignore_filters)?;
let mut files_only = ArchivableFilesFilter::new(Box::new(files));
- phar.build_from_iterator(&mut files_only, &sources);
- files_only.add_empty_dir(&phar, &sources);
+ phar.build_from_iterator(&mut files_only, &sources)?;
+ files_only.add_empty_dir(&phar, &sources)?;
if !file_exists(&target) {
let target = format!("{}.{}", filename, format);
@@ -137,7 +137,7 @@ impl ArchiverInterface for PharArchiver {
unlink(&target);
- phar.compress(compress_algo);
+ phar.compress(compress_algo)?;
let target = format!("{}.{}", filename, format);
return Ok(target);
diff --git a/crates/shirabe/src/util/error_handler.rs b/crates/shirabe/src/util/error_handler.rs
index 7a9cf9c2..2e574fe0 100644
--- a/crates/shirabe/src/util/error_handler.rs
+++ b/crates/shirabe/src/util/error_handler.rs
@@ -125,7 +125,7 @@ impl ErrorHandler {
}
if output_even_without_io {
- fwrite(&STDERR, &format!("Warning: {}{}", message, PHP_EOL), None);
+ fwrite(&STDERR, format!("Warning: {}{}", message, PHP_EOL), None);
}
}
}
diff --git a/crates/shirabe/src/util/perforce.rs b/crates/shirabe/src/util/perforce.rs
index fa448b35..9cd804a4 100644
--- a/crates/shirabe/src/util/perforce.rs
+++ b/crates/shirabe/src/util/perforce.rs
@@ -417,12 +417,12 @@ impl Perforce {
pub fn write_client_spec_to_file(&mut self, spec: &PhpResource) {
fwrite(
spec,
- &format!("Client: {}{}{}", self.get_client(), PHP_EOL, PHP_EOL),
+ format!("Client: {}{}{}", self.get_client(), PHP_EOL, PHP_EOL),
None,
);
fwrite(
spec,
- &format!(
+ format!(
"Update: {}{}{}",
date("Y/m/d H:i:s", None),
PHP_EOL,
@@ -432,12 +432,12 @@ impl Perforce {
);
fwrite(
spec,
- &format!("Access: {}{}", date("Y/m/d H:i:s", None), PHP_EOL),
+ format!("Access: {}{}", date("Y/m/d H:i:s", None), PHP_EOL),
None,
);
fwrite(
spec,
- &format!(
+ format!(
"Owner: {}{}{}",
self.get_user().unwrap_or_default(),
PHP_EOL,
@@ -445,10 +445,10 @@ impl Perforce {
),
None,
);
- fwrite(spec, &format!("Description:{}", PHP_EOL), None);
+ fwrite(spec, format!("Description:{}", PHP_EOL), None);
fwrite(
spec,
- &format!(
+ format!(
" Created by {} from composer.{}{}",
self.get_user().unwrap_or_default(),
PHP_EOL,
@@ -458,12 +458,12 @@ impl Perforce {
);
fwrite(
spec,
- &format!("Root: {}{}{}", self.get_path(), PHP_EOL, PHP_EOL),
+ format!("Root: {}{}{}", self.get_path(), PHP_EOL, PHP_EOL),
None,
);
fwrite(
spec,
- &format!(
+ format!(
"Options: noallwrite noclobber nocompress unlocked modtime rmdir{}{}",
PHP_EOL, PHP_EOL
),
@@ -471,20 +471,16 @@ impl Perforce {
);
fwrite(
spec,
- &format!("SubmitOptions: revertunchanged{}{}", PHP_EOL, PHP_EOL),
- None,
- );
- fwrite(
- spec,
- &format!("LineEnd: local{}{}", PHP_EOL, PHP_EOL),
+ format!("SubmitOptions: revertunchanged{}{}", PHP_EOL, PHP_EOL),
None,
);
+ fwrite(spec, format!("LineEnd: local{}{}", PHP_EOL, PHP_EOL), None);
if self.is_stream() {
- fwrite(spec, &format!("Stream:{}", PHP_EOL), None);
+ fwrite(spec, format!("Stream:{}", PHP_EOL), None);
let stream_clone = self.p4_stream.clone().unwrap_or_default();
fwrite(
spec,
- &format!(
+ format!(
" {}{}",
self.get_stream_without_label(&stream_clone),
PHP_EOL
@@ -496,7 +492,7 @@ impl Perforce {
let client = self.get_client();
fwrite(
spec,
- &format!("View: {}/... //{}/... {}", stream, client, PHP_EOL),
+ format!("View: {}/... //{}/... {}", stream, client, PHP_EOL),
None,
);
}
diff --git a/crates/shirabe/src/util/remote_filesystem.rs b/crates/shirabe/src/util/remote_filesystem.rs
index a244c9f9..2dafc0d3 100644
--- a/crates/shirabe/src/util/remote_filesystem.rs
+++ b/crates/shirabe/src/util/remote_filesystem.rs
@@ -1036,10 +1036,12 @@ impl RemoteFilesystem {
.unwrap_or(false);
if decode {
- let decoded = zlib_decode(result.as_deref().unwrap_or(""));
+ let decoded = zlib_decode(result.as_deref().unwrap_or("").as_bytes());
result = match decoded {
- Some(d) => Some(d),
+ // TODO(phase-e): byte-string semantics — the response body travels through
+ // RemoteFilesystem as a String; from_utf8_lossy can corrupt binary payloads
+ Some(d) => Some(String::from_utf8_lossy(&d).into_owned()),
None => {
return Err(anyhow::anyhow!(TransportException::new(
"Failed to decode zlib stream".to_string(),
diff --git a/crates/shirabe/src/util/tar.rs b/crates/shirabe/src/util/tar.rs
index 55b6a960..792d1c4f 100644
--- a/crates/shirabe/src/util/tar.rs
+++ b/crates/shirabe/src/util/tar.rs
@@ -7,7 +7,7 @@ pub struct Tar;
impl Tar {
pub fn get_composer_json(path_to_archive: &str) -> anyhow::Result<Option<String>> {
- let phar = PharData::new(path_to_archive.to_string());
+ let phar = PharData::new(path_to_archive.to_string())?;
if !phar.valid() {
return Ok(None);
@@ -16,9 +16,20 @@ impl Tar {
Ok(Some(Self::extract_composer_json_from_folder(&phar)?))
}
+ /// The content bytes are decoded strictly: a composer.json that is not valid
+ /// UTF-8 could never survive the JSON parsing that follows in PHP either.
+ fn content_to_string(content: Vec<u8>) -> anyhow::Result<String> {
+ String::from_utf8(content).map_err(|_| {
+ anyhow::anyhow!(RuntimeException {
+ message: "composer.json in the archive is not valid UTF-8".to_string(),
+ code: 0,
+ })
+ })
+ }
+
fn extract_composer_json_from_folder(phar: &PharData) -> anyhow::Result<String> {
if let Some(file) = phar.get("composer.json") {
- return Ok(file.get_content());
+ return Self::content_to_string(file.get_content());
}
let mut top_level_paths: IndexMap<String, bool> = IndexMap::new();
@@ -49,7 +60,7 @@ impl Tar {
if !top_level_paths.is_empty()
&& let Some(file) = phar.get(&composer_json_path)
{
- return Ok(file.get_content());
+ return Self::content_to_string(file.get_content());
}
Err(anyhow::anyhow!(RuntimeException {
diff --git a/crates/shirabe/tests/package/archiver/archive_manager_test.rs b/crates/shirabe/tests/package/archiver/archive_manager_test.rs
index d85fac10..3db1e734 100644
--- a/crates/shirabe/tests/package/archiver/archive_manager_test.rs
+++ b/crates/shirabe/tests/package/archiver/archive_manager_test.rs
@@ -166,12 +166,8 @@ fn test_unknown_format() {
}
// ref: ArchiveManagerTest::testArchiveTar / testArchiveCustomFileName.
-//
-// These drive ArchiveManager::archive end-to-end for the 'tar' format, which dispatches to
-// PharArchiver::archive. That builds the archive via PharData, whose build_from_iterator is
-// todo!() in the php-shim, so the archiving path cannot run yet.
#[test]
-#[ignore = "needs PharData tar archiving (new_with_format/build_from_iterator are todo!() in the php-shim) for ArchiveManager::archive('tar', ...)"]
+#[ignore = "ArchiveManager::archive always passes buildExcludePatterns' glob excludes (e.g. 'name-*.zip'), which BaseExcludeFilter::generate_pattern turns into look-ahead regexes the regex crate cannot compile"]
fn test_archive_tar() {
if !git_is_executable() {
return;
@@ -208,7 +204,7 @@ fn test_archive_tar() {
}
#[test]
-#[ignore = "needs PharData tar archiving (new_with_format/build_from_iterator are todo!() in the php-shim) for ArchiveManager::archive('tar', ...)"]
+#[ignore = "ArchiveManager::archive always passes buildExcludePatterns' glob excludes (e.g. 'name-*.zip'), which BaseExcludeFilter::generate_pattern turns into look-ahead regexes the regex crate cannot compile"]
fn test_archive_custom_file_name() {
if !git_is_executable() {
return;
diff --git a/crates/shirabe/tests/package/archiver/phar_archiver_test.rs b/crates/shirabe/tests/package/archiver/phar_archiver_test.rs
index fe6185d8..39c5087a 100644
--- a/crates/shirabe/tests/package/archiver/phar_archiver_test.rs
+++ b/crates/shirabe/tests/package/archiver/phar_archiver_test.rs
@@ -1,5 +1,6 @@
//! ref: composer/tests/Composer/Test/Package/Archiver/PharArchiverTest.php
+use serial_test::serial;
use shirabe::package::archiver::{ArchiverInterface, PharArchiver};
use shirabe::package::handle::CompletePackageHandle;
use shirabe::util::{Filesystem, Platform};
@@ -63,8 +64,9 @@ impl ArchiverTestCase {
}
}
-#[ignore = "PharArchiver::archive builds the archive via PharData, which is todo!() in the php-shim"]
+#[ignore = "the excludes passed here make BaseExcludeFilter::generate_pattern emit look-ahead regexes ((?=$|/) and Glob's (?=[^\\.])) that the regex crate cannot compile"]
#[test]
+#[serial]
fn test_tar_archive() {
let mut test_case = ArchiverTestCase::set_up();
@@ -98,8 +100,8 @@ fn test_tar_archive() {
.unwrap();
}
-#[ignore = "PharArchiver::archive builds the archive via PharData, which is todo!() in the php-shim"]
#[test]
+#[serial]
fn test_zip_archive() {
let mut test_case = ArchiverTestCase::set_up();
diff --git a/crates/shirabe/tests/repository/artifact_repository_test.rs b/crates/shirabe/tests/repository/artifact_repository_test.rs
index 2bfe85de..b13f23e9 100644
--- a/crates/shirabe/tests/repository/artifact_repository_test.rs
+++ b/crates/shirabe/tests/repository/artifact_repository_test.rs
@@ -33,7 +33,6 @@ fn create_repo(url: &str) -> ArtifactRepository {
}
#[test]
-#[ignore = "the artifacts fixtures dir contains a .tar file (jsonInRootTarFile); scanning it routes through Tar::get_composer_json -> PharData::new which is todo!()"]
fn test_extracts_configs_from_zip_archives() {
if set_up() {
return;
@@ -83,7 +82,6 @@ fn test_extracts_configs_from_zip_archives() {
}
#[test]
-#[ignore = "the artifacts fixtures dir contains a .tar file (jsonInRootTarFile); scanning it routes through Tar::get_composer_json -> PharData::new which is todo!()"]
fn test_absolute_repo_url_creates_absolute_url_packages() {
if set_up() {
return;
@@ -104,7 +102,7 @@ fn test_absolute_repo_url_creates_absolute_url_packages() {
}
#[test]
-#[ignore = "the relative url is resolved from the process cwd (the crate manifest dir under cargo, not the composer test root), so the artifacts dir is not found; additionally the dir contains a .tar file routing through PharData::new which is todo!()"]
+#[ignore = "the relative url is resolved from the process cwd (the crate manifest dir under cargo, not the composer test root), so the artifacts dir is not found"]
fn test_relative_repo_url_creates_relative_url_packages() {
if set_up() {
return;
diff --git a/crates/shirabe/tests/util/tar_test.rs b/crates/shirabe/tests/util/tar_test.rs
index 7a172a7a..50951202 100644
--- a/crates/shirabe/tests/util/tar_test.rs
+++ b/crates/shirabe/tests/util/tar_test.rs
@@ -12,7 +12,6 @@ fn fixture(name: &str) -> String {
}
#[test]
-#[ignore = "PharData::new() (crates/shirabe-php-shim/src/phar.rs:91) is still todo!(), which Tar::get_composer_json depends on for every fixture"]
fn test_returns_nullif_the_tar_is_not_found() {
let result = Tar::get_composer_json(&fixture("invalid.zip")).unwrap();
@@ -20,26 +19,22 @@ fn test_returns_nullif_the_tar_is_not_found() {
}
#[test]
-#[ignore = "PharData::new() (crates/shirabe-php-shim/src/phar.rs:91) is still todo!(), which Tar::get_composer_json depends on for every fixture"]
fn test_returns_null_if_the_tar_is_empty() {
let result = Tar::get_composer_json(&fixture("empty.tar.gz")).unwrap();
assert_eq!(None, result);
}
#[test]
-#[ignore = "PharData::new() (crates/shirabe-php-shim/src/phar.rs:91) is still todo!(), which Tar::get_composer_json depends on for every fixture"]
fn test_throws_exception_if_the_tar_has_no_composer_json() {
assert!(Tar::get_composer_json(&fixture("nojson.tar.gz")).is_err());
}
#[test]
-#[ignore = "PharData::new() (crates/shirabe-php-shim/src/phar.rs:91) is still todo!(), which Tar::get_composer_json depends on for every fixture"]
fn test_throws_exception_if_the_composer_json_is_in_a_sub_subfolder() {
assert!(Tar::get_composer_json(&fixture("subfolders.tar.gz")).is_err());
}
#[test]
-#[ignore = "PharData::new() (crates/shirabe-php-shim/src/phar.rs:91) is still todo!(), which Tar::get_composer_json depends on for every fixture"]
fn test_returns_composer_json_in_tar_root() {
let result = Tar::get_composer_json(&fixture("root.tar.gz")).unwrap();
assert_eq!(
@@ -49,7 +44,6 @@ fn test_returns_composer_json_in_tar_root() {
}
#[test]
-#[ignore = "PharData::new() (crates/shirabe-php-shim/src/phar.rs:91) is still todo!(), which Tar::get_composer_json depends on for every fixture"]
fn test_returns_composer_json_in_first_folder() {
let result = Tar::get_composer_json(&fixture("folder.tar.gz")).unwrap();
assert_eq!(
@@ -59,7 +53,6 @@ fn test_returns_composer_json_in_first_folder() {
}
#[test]
-#[ignore = "PharData::new() (crates/shirabe-php-shim/src/phar.rs:91) is still todo!(), which Tar::get_composer_json depends on for every fixture"]
fn test_multiple_top_level_dirs_is_invalid() {
assert!(Tar::get_composer_json(&fixture("multiple.tar.gz")).is_err());
}