Skip to main content

shuyi_cli/export/
archive.rs

1//! The archives packages are shipped in: a zip (Windows, the web) and a
2//! `tar.gz` (Linux). Both are written the same for the same files: every
3//! entry at one fixed time (1980-01-01), in the order given, so an export
4//! made twice gives the same bytes.
5
6use std::fs;
7use std::io::{Read as _, Write as _};
8use std::path::{Path, PathBuf};
9
10use flate2::write::DeflateEncoder;
11use flate2::{Compression, GzBuilder};
12
13/// A file to put in an archive.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct Entry {
16    /// Its name in the archive, with `/` between folders.
17    pub name: String,
18    /// The file on disk.
19    pub path: PathBuf,
20    /// Whether it is a program (mode 755 rather than 644).
21    pub executable: bool,
22}
23
24/// Every file under `dir`, named from `prefix` (`""` for the archive's
25/// root, `"game-1.0-linux/"` for a folder), sorted by name; `executable`
26/// says which are programs.
27pub fn entries(
28    dir: &Path,
29    prefix: &str,
30    executable: &dyn Fn(&Path) -> bool,
31) -> Result<Vec<Entry>, String> {
32    let mut found = Vec::new();
33    walk(dir, prefix, executable, &mut found)?;
34    found.sort_by(|a, b| a.name.cmp(&b.name));
35    Ok(found)
36}
37
38fn walk(
39    dir: &Path,
40    prefix: &str,
41    executable: &dyn Fn(&Path) -> bool,
42    found: &mut Vec<Entry>,
43) -> Result<(), String> {
44    let list = fs::read_dir(dir).map_err(|e| format!("read {}: {e}", dir.display()))?;
45    for item in list {
46        let path = item
47            .map_err(|e| format!("read {}: {e}", dir.display()))?
48            .path();
49        let name = format!(
50            "{prefix}{}",
51            path.file_name().unwrap_or_default().to_string_lossy()
52        );
53        if path.is_dir() {
54            walk(&path, &format!("{name}/"), executable, found)?;
55        } else {
56            found.push(Entry {
57                executable: executable(&path),
58                name,
59                path,
60            });
61        }
62    }
63    Ok(())
64}
65
66/// The DOS date of 1980-01-01, zip's first day; its time is 00:00.
67const DOS_DATE: u16 = (1 << 5) | 1;
68
69/// Writes `entries` into a zip at `out`, each deflated, with UTF-8 names.
70pub fn zip(out: &Path, entries: &[Entry]) -> Result<(), String> {
71    let mut body = Vec::new();
72    let mut central = Vec::new();
73    for entry in entries {
74        let data =
75            fs::read(&entry.path).map_err(|e| format!("read {}: {e}", entry.path.display()))?;
76        let mut encoder = DeflateEncoder::new(Vec::new(), Compression::best());
77        encoder
78            .write_all(&data)
79            .map_err(|e| format!("deflate {}: {e}", entry.name))?;
80        let packed = encoder
81            .finish()
82            .map_err(|e| format!("deflate {}: {e}", entry.name))?;
83        let too_big = || format!("zip: {} is over 4 GiB", entry.name);
84        let size = u32::try_from(data.len()).map_err(|_| too_big())?;
85        let packed_size = u32::try_from(packed.len()).map_err(|_| too_big())?;
86        let offset = u32::try_from(body.len()).map_err(|_| too_big())?;
87        let crc = crc32fast::hash(&data);
88        let name = entry.name.as_bytes();
89        let name_len = u16::try_from(name.len())
90            .map_err(|_| format!("zip: {} is too long a name", entry.name))?;
91        // The fields both headers share, from "version needed" to the
92        // name's length (extra field: none).
93        let mut shared = Vec::new();
94        for half in [20u16, 0x0800, 8, 0, DOS_DATE] {
95            shared.extend(half.to_le_bytes());
96        }
97        for word in [crc, packed_size, size] {
98            shared.extend(word.to_le_bytes());
99        }
100        shared.extend(name_len.to_le_bytes());
101        shared.extend(0u16.to_le_bytes());
102
103        body.extend(0x0403_4b50u32.to_le_bytes());
104        body.extend(&shared);
105        body.extend(name);
106        body.extend(&packed);
107
108        let mode: u32 = if entry.executable {
109            0o100_755
110        } else {
111            0o100_644
112        };
113        central.extend(0x0201_4b50u32.to_le_bytes());
114        // Made by Unix (3), zip 3.0: its external attributes are a mode.
115        central.extend(0x031eu16.to_le_bytes());
116        central.extend(&shared);
117        // Comment, disk, internal attributes: none.
118        central.extend([0u8; 6]);
119        central.extend((mode << 16).to_le_bytes());
120        central.extend(offset.to_le_bytes());
121        central.extend(name);
122    }
123    let count = u16::try_from(entries.len()).map_err(|_| "zip: over 65535 files".to_owned())?;
124    let too_big = || "zip: over 4 GiB".to_owned();
125    let central_size = u32::try_from(central.len()).map_err(|_| too_big())?;
126    let central_at = u32::try_from(body.len()).map_err(|_| too_big())?;
127    body.extend(central);
128    body.extend(0x0605_4b50u32.to_le_bytes());
129    body.extend([0u8; 4]);
130    body.extend(count.to_le_bytes());
131    body.extend(count.to_le_bytes());
132    body.extend(central_size.to_le_bytes());
133    body.extend(central_at.to_le_bytes());
134    body.extend(0u16.to_le_bytes());
135    fs::write(out, body).map_err(|e| format!("write {}: {e}", out.display()))
136}
137
138/// The names of the entries of the zip `bytes`, in order, from its
139/// central directory.
140pub fn zip_names(bytes: &[u8]) -> Result<Vec<String>, String> {
141    let end = bytes
142        .windows(4)
143        .rposition(|w| w == 0x0605_4b50u32.to_le_bytes())
144        .ok_or("zip: no end of central directory")?;
145    let half = |at: usize| u16::from_le_bytes([bytes[at], bytes[at + 1]]);
146    let word =
147        |at: usize| u32::from_le_bytes([bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]]);
148    let count = half(end + 10);
149    let mut at = word(end + 16) as usize;
150    let mut names = Vec::new();
151    for _ in 0..count {
152        if bytes.get(at..at + 4) != Some(&0x0201_4b50u32.to_le_bytes()[..]) {
153            return Err("zip: a broken central directory".to_owned());
154        }
155        let name_len = half(at + 28) as usize;
156        let skip = half(at + 30) as usize + half(at + 32) as usize;
157        let name = bytes
158            .get(at + 46..at + 46 + name_len)
159            .ok_or("zip: a name past the end")?;
160        names.push(String::from_utf8_lossy(name).into_owned());
161        at += 46 + name_len + skip;
162    }
163    Ok(names)
164}
165
166/// The files of the archive at `path`, a zip or a `tar.gz` as these functions
167/// write them: each name and its bytes, in order. An archive that unpacks to
168/// more than `limit` bytes is an error, as is one that doesn't read.
169pub fn read(path: &Path, limit: u64) -> Result<Vec<(String, Vec<u8>)>, String> {
170    let bytes = fs::read(path).map_err(|e| format!("read {}: {e}", path.display()))?;
171    let read = if path.to_string_lossy().ends_with(".tar.gz") {
172        read_tar_gz(&bytes, limit)
173    } else {
174        read_zip(&bytes, limit)
175    };
176    read.map_err(|e| format!("{}: {e}", path.display()))
177}
178
179/// The files of the zip `bytes`, by its central directory.
180fn read_zip(bytes: &[u8], limit: u64) -> Result<Vec<(String, Vec<u8>)>, String> {
181    let at = |at: usize, n: usize| bytes.get(at..at + n).ok_or("zip: cut short".to_owned());
182    let half = |offset: usize| at(offset, 2).map(|b| u16::from_le_bytes([b[0], b[1]]) as usize);
183    let word = |offset: usize| {
184        at(offset, 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as usize)
185    };
186    let end = bytes
187        .windows(4)
188        .rposition(|w| w == 0x0605_4b50u32.to_le_bytes())
189        .ok_or("zip: no end of central directory")?;
190    let mut next = word(end + 16)?;
191    let mut total = 0u64;
192    let mut files = Vec::new();
193    for _ in 0..half(end + 10)? {
194        if at(next, 4)? != 0x0201_4b50u32.to_le_bytes() {
195            return Err("zip: a broken central directory".to_owned());
196        }
197        let method = half(next + 10)?;
198        let packed = word(next + 20)?;
199        let size = word(next + 24)? as u64;
200        let name_len = half(next + 28)?;
201        let skip = half(next + 30)? + half(next + 32)?;
202        let local = word(next + 42)?;
203        let name = String::from_utf8_lossy(at(next + 46, name_len)?).into_owned();
204        total = total.saturating_add(size);
205        if total > limit {
206            return Err(format!("zip: over {limit} bytes unpacked"));
207        }
208        let start = local + 30 + half(local + 26)? + half(local + 28)?;
209        let data = at(start, packed)?;
210        let inner = match method {
211            0 => data.to_vec(),
212            8 => {
213                let mut inner = Vec::new();
214                flate2::read::DeflateDecoder::new(data)
215                    .take(size)
216                    .read_to_end(&mut inner)
217                    .map_err(|e| format!("zip: {name}: {e}"))?;
218                inner
219            }
220            other => return Err(format!("zip: {name}: compression method {other}")),
221        };
222        files.push((name, inner));
223        next += 46 + name_len + skip;
224    }
225    Ok(files)
226}
227
228/// The files of the `tar.gz` `bytes`, by their ustar headers.
229fn read_tar_gz(bytes: &[u8], limit: u64) -> Result<Vec<(String, Vec<u8>)>, String> {
230    let mut tar = Vec::new();
231    flate2::read::GzDecoder::new(bytes)
232        .take(limit.saturating_add(1))
233        .read_to_end(&mut tar)
234        .map_err(|e| format!("tar: {e}"))?;
235    if tar.len() as u64 > limit {
236        return Err(format!("tar: over {limit} bytes unpacked"));
237    }
238    let text = |field: &[u8]| {
239        let end = field.iter().position(|&b| b == 0).unwrap_or(field.len());
240        String::from_utf8_lossy(&field[..end]).into_owned()
241    };
242    let mut files = Vec::new();
243    let mut at = 0;
244    while let Some(header) = tar.get(at..at + 512).filter(|header| header[0] != 0) {
245        let name = text(&header[..100]);
246        let prefix = text(&header[345..500]);
247        let size = usize::from_str_radix(text(&header[124..136]).trim(), 8)
248            .map_err(|_| format!("tar: {name}: a broken size"))?;
249        let data = at + 512;
250        let inner = data
251            .checked_add(size)
252            .and_then(|end| tar.get(data..end))
253            .ok_or_else(|| format!("tar: {name}: cut short"))?;
254        let name = if prefix.is_empty() {
255            name
256        } else {
257            format!("{prefix}/{name}")
258        };
259        files.push((name, inner.to_vec()));
260        at = data + size.div_ceil(512) * 512;
261    }
262    Ok(files)
263}
264
265/// Writes `entries` into a gzip-compressed ustar archive at `out`.
266pub fn tar_gz(out: &Path, entries: &[Entry]) -> Result<(), String> {
267    let file = fs::File::create(out).map_err(|e| format!("write {}: {e}", out.display()))?;
268    let mut gz = GzBuilder::new().mtime(0).write(file, Compression::best());
269    let failed = |e: std::io::Error| format!("write {}: {e}", out.display());
270    for entry in entries {
271        let data =
272            fs::read(&entry.path).map_err(|e| format!("read {}: {e}", entry.path.display()))?;
273        let mode = if entry.executable { 0o755 } else { 0o644 };
274        gz.write_all(&tar_header(&entry.name, mode, data.len() as u64)?)
275            .map_err(failed)?;
276        gz.write_all(&data).map_err(failed)?;
277        let pad = (512 - data.len() % 512) % 512;
278        gz.write_all(&vec![0; pad]).map_err(failed)?;
279    }
280    gz.write_all(&[0; 1024]).map_err(failed)?;
281    gz.finish().map_err(failed)?;
282    Ok(())
283}
284
285/// A ustar header for a regular file `name` of `size` bytes and `mode`,
286/// owned by root, at time 0.
287fn tar_header(name: &str, mode: u32, size: u64) -> Result<[u8; 512], String> {
288    let mut header = [0u8; 512];
289    let (prefix, name) = if name.len() <= 100 {
290        ("", name)
291    } else {
292        name.char_indices()
293            .filter(|&(at, c)| c == '/' && at <= 155 && name.len() - at - 1 <= 100)
294            .map(|(at, _)| (&name[..at], &name[at + 1..]))
295            .next()
296            .ok_or_else(|| format!("tar: the name {name} is too long"))?
297    };
298    let put = |header: &mut [u8; 512], at: usize, text: &[u8]| {
299        header[at..at + text.len()].copy_from_slice(text);
300    };
301    put(&mut header, 0, name.as_bytes());
302    put(&mut header, 100, format!("{mode:07o}\0").as_bytes());
303    put(&mut header, 108, b"0000000\0");
304    put(&mut header, 116, b"0000000\0");
305    put(&mut header, 124, format!("{size:011o}\0").as_bytes());
306    put(&mut header, 136, b"00000000000\0");
307    put(&mut header, 148, b"        ");
308    header[156] = b'0';
309    put(&mut header, 257, b"ustar\0");
310    put(&mut header, 263, b"00");
311    put(&mut header, 265, b"root");
312    put(&mut header, 297, b"root");
313    put(&mut header, 345, prefix.as_bytes());
314    let sum: u32 = header.iter().map(|&b| u32::from(b)).sum();
315    put(&mut header, 148, format!("{sum:06o}\0 ").as_bytes());
316    Ok(header)
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    fn fixture(name: &str) -> PathBuf {
324        let dir = std::env::temp_dir().join(format!("shuyi-archive-{name}-{}", std::process::id()));
325        let _ = fs::remove_dir_all(&dir);
326        fs::create_dir_all(dir.join("pkg/sub")).unwrap();
327        fs::write(dir.join("pkg/game"), b"\x7fELF program").unwrap();
328        fs::write(dir.join("pkg/NOTICES.txt"), "notices\n".repeat(50)).unwrap();
329        fs::write(dir.join("pkg/sub/a.ogg"), b"ogg").unwrap();
330        dir
331    }
332
333    fn listed(dir: &Path, prefix: &str) -> Vec<Entry> {
334        entries(&dir.join("pkg"), prefix, &|path| path.ends_with("game")).unwrap()
335    }
336
337    #[test]
338    fn entries_are_sorted_and_named_from_the_prefix() {
339        let dir = fixture("entries");
340        let found = listed(&dir, "g-1.0-linux/");
341        let names: Vec<&str> = found.iter().map(|e| e.name.as_str()).collect();
342        assert_eq!(
343            names,
344            [
345                "g-1.0-linux/NOTICES.txt",
346                "g-1.0-linux/game",
347                "g-1.0-linux/sub/a.ogg"
348            ]
349        );
350        assert!(found[1].executable && !found[0].executable);
351        assert!(entries(&dir.join("missing"), "", &|_| false).is_err());
352        fs::remove_dir_all(&dir).unwrap();
353    }
354
355    #[test]
356    fn a_zip_lists_its_files_and_inflates_to_them_the_same_each_time() {
357        let dir = fixture("zip");
358        let found = listed(&dir, "");
359        zip(&dir.join("a.zip"), &found).unwrap();
360        zip(&dir.join("b.zip"), &found).unwrap();
361        let bytes = fs::read(dir.join("a.zip")).unwrap();
362        assert_eq!(bytes, fs::read(dir.join("b.zip")).unwrap());
363        assert_eq!(
364            zip_names(&bytes).unwrap(),
365            ["NOTICES.txt", "game", "sub/a.ogg"]
366        );
367        // The first entry's data, inflated, is the file.
368        let name_len = u16::from_le_bytes([bytes[26], bytes[27]]) as usize;
369        let packed = u32::from_le_bytes([bytes[18], bytes[19], bytes[20], bytes[21]]) as usize;
370        let start = 30 + name_len;
371        let mut out = String::new();
372        flate2::read::DeflateDecoder::new(&bytes[start..start + packed])
373            .read_to_string(&mut out)
374            .unwrap();
375        assert_eq!(out, "notices\n".repeat(50));
376        assert!(zip_names(b"not a zip").is_err());
377        fs::remove_dir_all(&dir).unwrap();
378    }
379
380    #[test]
381    fn a_tar_gz_holds_each_file_with_its_mode() {
382        let dir = fixture("tar");
383        tar_gz(&dir.join("a.tar.gz"), &listed(&dir, "g/")).unwrap();
384        let mut tar = Vec::new();
385        flate2::read::GzDecoder::new(fs::File::open(dir.join("a.tar.gz")).unwrap())
386            .read_to_end(&mut tar)
387            .unwrap();
388        // Three headers and their data, padded, then two zero blocks.
389        assert_eq!(tar.len(), 512 * (2 + 2 + 2 + 2));
390        assert_eq!(&tar[..13], b"g/NOTICES.txt");
391        assert_eq!(&tar[100..107], b"0000644");
392        assert_eq!(&tar[1024..1030], b"g/game");
393        assert_eq!(&tar[1024 + 100..1024 + 107], b"0000755");
394        assert_eq!(&tar[257..262], b"ustar");
395        // The checksum is the header's bytes summed, its own field as
396        // spaces.
397        let mut header = tar[..512].to_vec();
398        header[148..156].copy_from_slice(b"        ");
399        let sum: u32 = header.iter().map(|&b| u32::from(b)).sum();
400        assert_eq!(&tar[148..155], format!("{sum:06o}\0").as_bytes());
401        fs::remove_dir_all(&dir).unwrap();
402    }
403
404    #[test]
405    fn a_long_tar_name_goes_in_the_prefix() {
406        let long = format!("{}/{}", "d".repeat(120), "f".repeat(50));
407        let header = tar_header(&long, 0o644, 1).unwrap();
408        assert_eq!(&header[..50], "f".repeat(50).as_bytes());
409        assert_eq!(&header[345..465], "d".repeat(120).as_bytes());
410        assert!(tar_header(&"x".repeat(300), 0o644, 1).is_err());
411    }
412}