Skip to main content

banished_nexus/
install.rs

1//! The `.pkm` packages in a downloaded file.
2//!
3//! Banished mods on Nexus Mods come as archives with the `.pkm` package inside (and often a
4//! readme). Of the 80 mods listed in October 2026, the main file was a `.zip` for 62, a `.7z`
5//! for 7 and a `.rar` for 9. This module opens a `.zip` or a bare `.pkm`; for `.7z` and `.rar`
6//! it says the player must unpack the file themselves.
7
8use std::io::Read;
9
10use crate::Error;
11
12/// The largest package taken from an archive, so a damaged or hostile archive cannot fill the
13/// memory or the disk.
14pub const MAX_PACKAGE: u64 = 1 << 30;
15
16/// A mod package to install: its file name (no folders) and its bytes.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct Package {
19    /// The file name, ending in `.pkm`, with any folders in the archive removed.
20    pub name: String,
21    /// The package's bytes.
22    pub data: Vec<u8>,
23}
24
25/// The kind of a download, from its first bytes.
26fn kind(data: &[u8]) -> Option<&'static str> {
27    if data.starts_with(b"PK\x03\x04") || data.starts_with(b"PK\x05\x06") {
28        Some("zip")
29    } else if data.starts_with(b"7z\xbc\xaf\x27\x1c") {
30        Some("7z")
31    } else if data.starts_with(b"Rar!\x1a\x07") {
32        Some("rar")
33    } else {
34        None
35    }
36}
37
38/// The last part of a path in an archive (either slash), if it is a `.pkm` file name.
39fn package_name(path: &str) -> Option<String> {
40    let name = path.rsplit(['/', '\\']).next()?.trim();
41    let ok = name.len() > 4
42        && name.to_ascii_lowercase().ends_with(".pkm")
43        && !name.starts_with('.')
44        && !name.contains(['\0', ':']);
45    ok.then(|| name.to_string())
46}
47
48/// The packages in a download named `file_name` (the name decides only for a bare `.pkm`).
49pub fn packages(file_name: &str, data: &[u8]) -> Result<Vec<Package>, Error> {
50    match kind(data) {
51        Some("zip") => from_zip(data),
52        Some(other) => Err(Error::Unsupported(other.to_string())),
53        None => match package_name(file_name) {
54            Some(name) => Ok(vec![Package {
55                name,
56                data: data.to_vec(),
57            }]),
58            None => Err(Error::Unsupported(crate::api::extension(file_name))),
59        },
60    }
61}
62
63fn from_zip(data: &[u8]) -> Result<Vec<Package>, Error> {
64    let bad = |e: zip::result::ZipError| Error::Answer(format!("the zip file is damaged: {e}"));
65    let mut archive = zip::ZipArchive::new(std::io::Cursor::new(data)).map_err(bad)?;
66    let mut out: Vec<Package> = Vec::new();
67    for i in 0..archive.len() {
68        let mut entry = archive.by_index(i).map_err(bad)?;
69        if !entry.is_file() {
70            continue;
71        }
72        let Some(name) = entry.name().ok().and_then(|n| package_name(&n)) else {
73            continue;
74        };
75        if entry.size() > MAX_PACKAGE {
76            return Err(Error::Answer(format!("{name} is too large")));
77        }
78        let mut bytes = Vec::with_capacity(entry.size() as usize);
79        (&mut entry)
80            .take(MAX_PACKAGE + 1)
81            .read_to_end(&mut bytes)
82            .map_err(|e| Error::Answer(format!("could not unpack {name}: {e}")))?;
83        if bytes.len() as u64 > MAX_PACKAGE {
84            return Err(Error::Answer(format!("{name} is too large")));
85        }
86        // Two folders with the same package: keep the first.
87        if !out.iter().any(|p| p.name.eq_ignore_ascii_case(&name)) {
88            out.push(Package { name, data: bytes });
89        }
90    }
91    if out.is_empty() {
92        return Err(Error::NoPackage);
93    }
94    Ok(out)
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use std::io::Write;
101
102    fn zip_of(files: &[(&str, &[u8])]) -> Vec<u8> {
103        let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
104        for (name, data) in files {
105            w.start_file(*name, zip::write::SimpleFileOptions::default())
106                .unwrap();
107            w.write_all(data).unwrap();
108        }
109        w.finish().unwrap().into_inner()
110    }
111
112    #[test]
113    fn takes_packages_from_a_zip() {
114        let z = zip_of(&[
115            ("Readme.txt", b"hello"),
116            ("MoreTerrain/MoreTerrain.pkm", b"PKG1"),
117            ("../../evil/Other.PKM", b"PKG2"),
118        ]);
119        let p = packages("More Terrain v6-19-6.zip", &z).unwrap();
120        let names: Vec<&str> = p.iter().map(|p| p.name.as_str()).collect();
121        assert_eq!(names, ["MoreTerrain.pkm", "Other.PKM"]);
122        assert_eq!(p[0].data, b"PKG1");
123        assert_eq!(
124            packages("x.zip", &zip_of(&[("a.txt", b"x")])),
125            Err(Error::NoPackage)
126        );
127    }
128
129    #[test]
130    fn bare_packages_and_other_archives() {
131        assert_eq!(
132            packages("Apiary.pkm", b"data").unwrap()[0].name,
133            "Apiary.pkm"
134        );
135        assert_eq!(
136            packages("a.rar", b"Rar!\x1a\x07\x00rest"),
137            Err(Error::Unsupported("rar".into()))
138        );
139        assert_eq!(
140            packages("a.7z", b"7z\xbc\xaf\x27\x1crest"),
141            Err(Error::Unsupported("7z".into()))
142        );
143        assert_eq!(
144            packages("notes.txt", b"x"),
145            Err(Error::Unsupported("txt".into()))
146        );
147    }
148}