Skip to content

Commit 0c865d2

Browse files
committed
Fix build
1 parent d428e23 commit 0c865d2

1 file changed

Lines changed: 393 additions & 0 deletions

File tree

src/phar.rs

Lines changed: 393 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,393 @@
1+
//! Minimal reader for PHP phar archives.
2+
//!
3+
//! Parses the phar binary format to extract PHP source files without
4+
//! requiring PHP or any external tools. Used during Composer autoload
5+
//! scanning to discover classes inside phar-distributed packages
6+
//! (e.g. PHPStan).
7+
//!
8+
//! Only uncompressed phars are supported (compressed file entries are
9+
//! silently skipped). This covers the most common case — PHPStan's
10+
//! phar contains only uncompressed files.
11+
12+
use std::collections::HashMap;
13+
14+
/// The marker that ends the phar stub.
15+
const HALT_COMPILER_MARKER: &[u8] = b"__HALT_COMPILER(); ?>";
16+
17+
/// Compression flag mask (bits 12–15).
18+
const COMPRESSION_MASK: u32 = 0xF000;
19+
20+
/// A parsed phar archive with random-access file extraction.
21+
pub(crate) struct PharArchive {
22+
/// Raw bytes of the entire phar file.
23+
data: Vec<u8>,
24+
/// Map of internal file path → (offset into `data`, uncompressed size).
25+
files: HashMap<String, (usize, usize)>,
26+
}
27+
28+
impl PharArchive {
29+
/// Parse a phar archive from raw bytes.
30+
/// Returns `None` if the format is invalid or unsupported.
31+
pub fn parse(data: Vec<u8>) -> Option<Self> {
32+
// 1. Find the stub end marker.
33+
let marker_pos = find_marker(&data)?;
34+
35+
// The manifest starts after the marker + a line ending (\r\n or \n).
36+
let after_marker = marker_pos + HALT_COMPILER_MARKER.len();
37+
let manifest_start = if data.get(after_marker..after_marker + 2) == Some(b"\r\n") {
38+
after_marker + 2
39+
} else if data.get(after_marker..after_marker + 1) == Some(b"\n") {
40+
after_marker + 1
41+
} else {
42+
return None;
43+
};
44+
45+
let mut cursor = manifest_start;
46+
47+
// 2. Parse the manifest header.
48+
let manifest_length = read_u32(&data, &mut cursor)? as usize;
49+
let manifest_end = manifest_start + 4 + manifest_length;
50+
if manifest_end > data.len() {
51+
return None;
52+
}
53+
54+
let file_count = read_u32(&data, &mut cursor)? as usize;
55+
let _api_version = read_u16(&data, &mut cursor)?;
56+
let _global_flags = read_u32(&data, &mut cursor)?;
57+
58+
// Alias: 4-byte length + alias bytes.
59+
let alias_len = read_u32(&data, &mut cursor)? as usize;
60+
if cursor + alias_len > data.len() {
61+
return None;
62+
}
63+
cursor += alias_len; // skip alias bytes
64+
65+
// Metadata: 4-byte length + metadata bytes.
66+
let metadata_len = read_u32(&data, &mut cursor)? as usize;
67+
if cursor + metadata_len > data.len() {
68+
return None;
69+
}
70+
cursor += metadata_len; // skip metadata bytes
71+
72+
// 3. Parse each file entry and build the index.
73+
// We collect entries first, then compute offsets into the data area.
74+
struct RawEntry {
75+
filename: String,
76+
uncompressed_size: usize,
77+
compressed_size: usize,
78+
flags: u32,
79+
}
80+
81+
let mut entries = Vec::with_capacity(file_count);
82+
83+
for _ in 0..file_count {
84+
let filename_len = read_u32(&data, &mut cursor)? as usize;
85+
if cursor + filename_len > data.len() {
86+
return None;
87+
}
88+
let filename =
89+
String::from_utf8_lossy(&data[cursor..cursor + filename_len]).into_owned();
90+
cursor += filename_len;
91+
92+
let uncompressed_size = read_u32(&data, &mut cursor)? as usize;
93+
let _timestamp = read_u32(&data, &mut cursor)?;
94+
let compressed_size = read_u32(&data, &mut cursor)? as usize;
95+
let _crc32 = read_u32(&data, &mut cursor)?;
96+
let flags = read_u32(&data, &mut cursor)?;
97+
98+
// Per-file metadata.
99+
let file_metadata_len = read_u32(&data, &mut cursor)? as usize;
100+
if cursor + file_metadata_len > data.len() {
101+
return None;
102+
}
103+
cursor += file_metadata_len;
104+
105+
entries.push(RawEntry {
106+
filename,
107+
uncompressed_size,
108+
compressed_size,
109+
flags,
110+
});
111+
}
112+
113+
// 4. The file content area starts right after the manifest.
114+
// manifest_start + 4 (manifest_length field) + manifest_length.
115+
let data_area_start = manifest_end;
116+
117+
let mut files = HashMap::with_capacity(file_count);
118+
let mut offset = data_area_start;
119+
120+
for entry in &entries {
121+
// Only index uncompressed files (compression bits 12–15 clear).
122+
if entry.flags & COMPRESSION_MASK == 0 {
123+
if offset + entry.compressed_size > data.len() {
124+
return None;
125+
}
126+
files.insert(entry.filename.clone(), (offset, entry.uncompressed_size));
127+
}
128+
offset += entry.compressed_size;
129+
}
130+
131+
Some(Self { data, files })
132+
}
133+
134+
/// Extract the content of a file inside the phar.
135+
pub fn read_file(&self, internal_path: &str) -> Option<&[u8]> {
136+
let &(offset, size) = self.files.get(internal_path)?;
137+
self.data.get(offset..offset + size)
138+
}
139+
140+
/// Iterate over all file paths in the archive.
141+
pub fn file_paths(&self) -> impl Iterator<Item = &str> {
142+
self.files.keys().map(String::as_str)
143+
}
144+
}
145+
146+
// ─── Helpers ────────────────────────────────────────────────────────────────
147+
148+
/// Find the byte offset where `__HALT_COMPILER(); ?>` begins.
149+
fn find_marker(data: &[u8]) -> Option<usize> {
150+
let marker = HALT_COMPILER_MARKER;
151+
let len = marker.len();
152+
if data.len() < len {
153+
return None;
154+
}
155+
for i in 0..=data.len() - len {
156+
if &data[i..i + len] == marker {
157+
return Some(i);
158+
}
159+
}
160+
None
161+
}
162+
163+
/// Read a little-endian `u32` from `data` at `*cursor`, advancing the cursor.
164+
fn read_u32(data: &[u8], cursor: &mut usize) -> Option<u32> {
165+
let end = *cursor + 4;
166+
if end > data.len() {
167+
return None;
168+
}
169+
let bytes: [u8; 4] = data[*cursor..end].try_into().ok()?;
170+
*cursor = end;
171+
Some(u32::from_le_bytes(bytes))
172+
}
173+
174+
/// Read a little-endian `u16` from `data` at `*cursor`, advancing the cursor.
175+
fn read_u16(data: &[u8], cursor: &mut usize) -> Option<u16> {
176+
let end = *cursor + 2;
177+
if end > data.len() {
178+
return None;
179+
}
180+
let bytes: [u8; 2] = data[*cursor..end].try_into().ok()?;
181+
*cursor = end;
182+
Some(u16::from_le_bytes(bytes))
183+
}
184+
185+
// ─── Tests ──────────────────────────────────────────────────────────────────
186+
187+
#[cfg(test)]
188+
mod tests {
189+
use super::*;
190+
191+
/// Build a minimal valid phar archive in memory with the given files.
192+
///
193+
/// Each entry is `(filename, content)`. All files are stored uncompressed.
194+
fn build_test_phar(files: &[(&str, &[u8])]) -> Vec<u8> {
195+
let mut buf = Vec::new();
196+
197+
// ── Stub ────────────────────────────────────────────────────
198+
buf.extend_from_slice(b"<?php __HALT_COMPILER(); ?>\n");
199+
200+
// ── Manifest ────────────────────────────────────────────────
201+
// We build the manifest body first so we can compute its length.
202+
let mut manifest_body = Vec::new();
203+
204+
// File count (u32 LE).
205+
manifest_body.extend_from_slice(&(files.len() as u32).to_le_bytes());
206+
// API version 1.1.0 → 0x1100 stored LE (only 2 bytes).
207+
manifest_body.extend_from_slice(&0x1100u16.to_le_bytes());
208+
// Global flags (0 = no signature).
209+
manifest_body.extend_from_slice(&0u32.to_le_bytes());
210+
// Alias length + alias (empty).
211+
manifest_body.extend_from_slice(&0u32.to_le_bytes());
212+
// Metadata length + metadata (empty).
213+
manifest_body.extend_from_slice(&0u32.to_le_bytes());
214+
215+
// File entries.
216+
for (name, content) in files {
217+
let name_bytes = name.as_bytes();
218+
// Filename length + filename.
219+
manifest_body.extend_from_slice(&(name_bytes.len() as u32).to_le_bytes());
220+
manifest_body.extend_from_slice(name_bytes);
221+
// Uncompressed size.
222+
manifest_body.extend_from_slice(&(content.len() as u32).to_le_bytes());
223+
// Timestamp.
224+
manifest_body.extend_from_slice(&0u32.to_le_bytes());
225+
// Compressed size (same as uncompressed — no compression).
226+
manifest_body.extend_from_slice(&(content.len() as u32).to_le_bytes());
227+
// CRC32 (0 for testing).
228+
manifest_body.extend_from_slice(&0u32.to_le_bytes());
229+
// Flags (0 = uncompressed, no signature verification).
230+
manifest_body.extend_from_slice(&0u32.to_le_bytes());
231+
// Metadata length (0).
232+
manifest_body.extend_from_slice(&0u32.to_le_bytes());
233+
}
234+
235+
// Manifest length (does NOT include the 4 bytes of itself).
236+
buf.extend_from_slice(&(manifest_body.len() as u32).to_le_bytes());
237+
buf.extend_from_slice(&manifest_body);
238+
239+
// ── File contents ───────────────────────────────────────────
240+
for (_name, content) in files {
241+
buf.extend_from_slice(content);
242+
}
243+
244+
buf
245+
}
246+
247+
#[test]
248+
fn parse_minimal_phar() {
249+
let phar_bytes = build_test_phar(&[
250+
("src/Foo.php", b"<?php class Foo {}"),
251+
("src/Bar.php", b"<?php class Bar {}"),
252+
]);
253+
254+
let archive = PharArchive::parse(phar_bytes).expect("should parse valid phar");
255+
256+
assert_eq!(archive.files.len(), 2);
257+
assert!(archive.files.contains_key("src/Foo.php"));
258+
assert!(archive.files.contains_key("src/Bar.php"));
259+
}
260+
261+
#[test]
262+
fn read_file_returns_correct_content() {
263+
let foo_content = b"<?php class Foo { public function hello() {} }";
264+
let bar_content = b"<?php class Bar extends Foo {}";
265+
266+
let phar_bytes =
267+
build_test_phar(&[("src/Foo.php", foo_content), ("src/Bar.php", bar_content)]);
268+
269+
let archive = PharArchive::parse(phar_bytes).expect("should parse");
270+
271+
assert_eq!(
272+
archive.read_file("src/Foo.php"),
273+
Some(foo_content.as_slice())
274+
);
275+
assert_eq!(
276+
archive.read_file("src/Bar.php"),
277+
Some(bar_content.as_slice())
278+
);
279+
assert_eq!(archive.read_file("src/Missing.php"), None);
280+
}
281+
282+
#[test]
283+
fn file_paths_lists_all_files() {
284+
let phar_bytes = build_test_phar(&[
285+
("a.php", b"<?php // a"),
286+
("b.php", b"<?php // b"),
287+
("c.php", b"<?php // c"),
288+
]);
289+
290+
let archive = PharArchive::parse(phar_bytes).expect("should parse");
291+
292+
let mut paths: Vec<&str> = archive.file_paths().collect();
293+
paths.sort();
294+
295+
assert_eq!(paths, vec!["a.php", "b.php", "c.php"]);
296+
}
297+
298+
#[test]
299+
fn parse_returns_none_for_garbage() {
300+
assert!(PharArchive::parse(vec![0, 1, 2, 3]).is_none());
301+
assert!(PharArchive::parse(Vec::new()).is_none());
302+
}
303+
304+
#[test]
305+
fn crlf_line_ending_after_marker() {
306+
let foo_content = b"<?php class Foo {}";
307+
308+
// Build the phar manually with \r\n after the marker.
309+
let mut buf = Vec::new();
310+
buf.extend_from_slice(b"<?php __HALT_COMPILER(); ?>\r\n");
311+
312+
// Manifest body.
313+
let mut manifest_body = Vec::new();
314+
manifest_body.extend_from_slice(&1u32.to_le_bytes()); // file count
315+
manifest_body.extend_from_slice(&0x1100u16.to_le_bytes()); // API version
316+
manifest_body.extend_from_slice(&0u32.to_le_bytes()); // global flags
317+
manifest_body.extend_from_slice(&0u32.to_le_bytes()); // alias len
318+
manifest_body.extend_from_slice(&0u32.to_le_bytes()); // metadata len
319+
320+
// Single file entry.
321+
let name = b"Foo.php";
322+
manifest_body.extend_from_slice(&(name.len() as u32).to_le_bytes());
323+
manifest_body.extend_from_slice(name);
324+
manifest_body.extend_from_slice(&(foo_content.len() as u32).to_le_bytes());
325+
manifest_body.extend_from_slice(&0u32.to_le_bytes()); // timestamp
326+
manifest_body.extend_from_slice(&(foo_content.len() as u32).to_le_bytes());
327+
manifest_body.extend_from_slice(&0u32.to_le_bytes()); // crc32
328+
manifest_body.extend_from_slice(&0u32.to_le_bytes()); // flags
329+
manifest_body.extend_from_slice(&0u32.to_le_bytes()); // metadata len
330+
331+
buf.extend_from_slice(&(manifest_body.len() as u32).to_le_bytes());
332+
buf.extend_from_slice(&manifest_body);
333+
buf.extend_from_slice(foo_content);
334+
335+
let archive = PharArchive::parse(buf).expect("should parse with CRLF");
336+
assert_eq!(archive.read_file("Foo.php"), Some(foo_content.as_slice()));
337+
}
338+
339+
#[test]
340+
fn compressed_entries_are_skipped() {
341+
// Build a phar with one uncompressed and one "compressed" entry.
342+
let good_content = b"<?php class Good {}";
343+
let compressed_content = b"compressed-data";
344+
345+
let mut buf = Vec::new();
346+
buf.extend_from_slice(b"<?php __HALT_COMPILER(); ?>\n");
347+
348+
let mut manifest_body = Vec::new();
349+
manifest_body.extend_from_slice(&2u32.to_le_bytes()); // 2 files
350+
manifest_body.extend_from_slice(&0x1100u16.to_le_bytes());
351+
manifest_body.extend_from_slice(&0u32.to_le_bytes());
352+
manifest_body.extend_from_slice(&0u32.to_le_bytes()); // alias
353+
manifest_body.extend_from_slice(&0u32.to_le_bytes()); // metadata
354+
355+
// Entry 1: uncompressed.
356+
let name1 = b"Good.php";
357+
manifest_body.extend_from_slice(&(name1.len() as u32).to_le_bytes());
358+
manifest_body.extend_from_slice(name1);
359+
manifest_body.extend_from_slice(&(good_content.len() as u32).to_le_bytes());
360+
manifest_body.extend_from_slice(&0u32.to_le_bytes());
361+
manifest_body.extend_from_slice(&(good_content.len() as u32).to_le_bytes());
362+
manifest_body.extend_from_slice(&0u32.to_le_bytes());
363+
manifest_body.extend_from_slice(&0u32.to_le_bytes()); // flags = 0 (uncompressed)
364+
manifest_body.extend_from_slice(&0u32.to_le_bytes());
365+
366+
// Entry 2: zlib compressed (flag 0x1000).
367+
let name2 = b"Compressed.php";
368+
manifest_body.extend_from_slice(&(name2.len() as u32).to_le_bytes());
369+
manifest_body.extend_from_slice(name2);
370+
manifest_body.extend_from_slice(&100u32.to_le_bytes()); // uncompressed
371+
manifest_body.extend_from_slice(&0u32.to_le_bytes());
372+
manifest_body.extend_from_slice(&(compressed_content.len() as u32).to_le_bytes()); // compressed
373+
manifest_body.extend_from_slice(&0u32.to_le_bytes());
374+
manifest_body.extend_from_slice(&0x1000u32.to_le_bytes()); // flags: zlib
375+
manifest_body.extend_from_slice(&0u32.to_le_bytes());
376+
377+
buf.extend_from_slice(&(manifest_body.len() as u32).to_le_bytes());
378+
buf.extend_from_slice(&manifest_body);
379+
buf.extend_from_slice(good_content);
380+
buf.extend_from_slice(compressed_content);
381+
382+
let archive = PharArchive::parse(buf).expect("should parse");
383+
384+
// Good.php should be readable.
385+
assert_eq!(archive.read_file("Good.php"), Some(good_content.as_slice()));
386+
// Compressed.php should be skipped.
387+
assert!(archive.read_file("Compressed.php").is_none());
388+
// Only one file path should be listed.
389+
let paths: Vec<&str> = archive.file_paths().collect();
390+
assert_eq!(paths.len(), 1);
391+
assert_eq!(paths[0], "Good.php");
392+
}
393+
}

0 commit comments

Comments
 (0)