Skip to main content

pacenotes_installer\engine/
preflight.rs

1#[derive(Clone, Debug, PartialEq, Eq)]
2pub enum PreflightError {
3    PayloadMissing {
4        package: String,
5    },
6    /// The cache file rotted after it was verified at download time.
7    PayloadCorrupt {
8        package: String,
9    },
10    PayloadUnreadable {
11        package: String,
12        reason: String,
13    },
14    /// `needed` counts what is written plus the copies of what it replaces.
15    NotEnoughSpace {
16        needed: u64,
17        available: u64,
18    },
19    NotWritable {
20        reason: String,
21    },
22    /// The game holds its files open, so a write would fail partway.
23    GameRunning,
24}
25
26impl std::fmt::Display for PreflightError {
27    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        match self {
29            PreflightError::PayloadMissing { package } => {
30                write!(formatter, "{package} is not in the download cache")
31            }
32            PreflightError::PayloadCorrupt { package } => write!(
33                formatter,
34                "{package} is damaged in the download cache; delete it and download it again"
35            ),
36            PreflightError::PayloadUnreadable { package, reason } => {
37                write!(formatter, "{package} could not be read: {reason}")
38            }
39            PreflightError::NotEnoughSpace { needed, available } => write!(
40                formatter,
41                "this needs {} and the drive has {}",
42                crate::size::human(*needed),
43                crate::size::human(*available)
44            ),
45            PreflightError::NotWritable { reason } => {
46                write!(formatter, "the game folder cannot be written to: {reason}")
47            }
48            PreflightError::GameRunning => write!(
49                formatter,
50                "the game is running. Close it first — its files are open, and the \
51                 installation would stop partway through"
52            ),
53        }
54    }
55}
56
57/// What the caller measures through Win32, so this module stays testable off Windows.
58#[derive(Clone, Copy, Debug, Default)]
59pub struct HostFacts {
60    /// Free bytes on the game folder's volume; `None` skips the space check.
61    pub free_bytes: Option<u64>,
62    pub game_running: bool,
63}
64
65/// What `execute` reuses, so no archive is opened twice.
66#[derive(Debug)]
67pub struct Preflight {
68    /// Per package to install, in archive order.
69    pub files: std::collections::BTreeMap<String, Vec<crate::zip::FileEntry>>,
70    pub install_bytes: u64,
71}
72
73impl Preflight {
74    /// In the shape `overlaps` takes.
75    pub fn paths(&self) -> std::collections::BTreeMap<String, Vec<String>> {
76        self.files
77            .iter()
78            .map(|(key, files)| {
79                let paths = files.iter().map(|file| file.path.clone()).collect();
80                (key.clone(), paths)
81            })
82            .collect()
83    }
84}
85
86/// Everything that can fail is checked before the first file is touched, since
87/// a failure after an uninstall leaves neither the old content nor the new.
88///
89/// Each payload is verified by its sha256, which reads the file once instead of
90/// decompressing every entry; `zip` still checks each entry's CRC on extraction.
91pub fn preflight(
92    plan: &super::Plan,
93    game_dir: &std::path::Path,
94    cache: &crate::cache::Cache,
95    facts: HostFacts,
96    watcher: &mut dyn super::Watcher,
97) -> Result<Preflight, PreflightError> {
98    if facts.game_running {
99        return Err(PreflightError::GameRunning);
100    }
101    probe_writable(game_dir)?;
102
103    let mut files = std::collections::BTreeMap::new();
104    let mut install_bytes = 0u64;
105    let installs = plan.installs();
106    for (index, key) in installs.iter().enumerate() {
107        watcher.phase(
108            super::Phase::Checking,
109            key,
110            index as u64,
111            installs.len() as u64,
112        );
113        let missing = || PreflightError::PayloadMissing {
114            package: key.to_string(),
115        };
116        let sha256 = &cache.get(key).ok_or_else(missing)?.sha256;
117        let listed = verified_files(cache, key, sha256)?;
118        install_bytes += listed.iter().map(|file| file.size).sum::<u64>();
119        files.insert(key.to_string(), listed);
120    }
121
122    check_space(install_bytes, facts)?;
123    Ok(Preflight {
124        files,
125        install_bytes,
126    })
127}
128
129/// The files of the payload named by `sha256`, checked against it; by digest
130/// rather than through the index, which holds only the newest version, so a
131/// restore can name an older one.
132pub(crate) fn verified_files(
133    cache: &crate::cache::Cache,
134    key: &str,
135    sha256: &str,
136) -> Result<Vec<crate::zip::FileEntry>, PreflightError> {
137    let bytes =
138        std::fs::read(cache.payload_path(sha256)).map_err(|_| PreflightError::PayloadMissing {
139            package: key.to_string(),
140        })?;
141    if crate::sha256::hex_digest(&bytes) != sha256 {
142        return Err(PreflightError::PayloadCorrupt {
143            package: key.to_string(),
144        });
145    }
146    crate::zip::entries(&bytes).map_err(|e| PreflightError::PayloadUnreadable {
147        package: key.to_string(),
148        reason: e.0,
149    })
150}
151
152/// Room for what is written plus copies of what it replaces, doubled as if every
153/// file replaced one its size, which errs on the safe side.
154pub(crate) fn check_space(install_bytes: u64, facts: HostFacts) -> Result<(), PreflightError> {
155    if let Some(available) = facts.free_bytes {
156        let needed = install_bytes.saturating_mul(2);
157        if needed > available {
158            return Err(PreflightError::NotEnoughSpace { needed, available });
159        }
160    }
161    Ok(())
162}
163
164/// Whether this account can write to `game_dir`, asked when the user settles on
165/// it and before anything is installed, so it removes whatever the probe
166/// created: a folder the user then abandons keeps no trace.
167pub fn check_writable(game_dir: &std::path::Path) -> Result<(), PreflightError> {
168    let dir = game_dir.join(crate::ledger::STATE_DIR);
169    let created = !dir.exists();
170    let probed = probe_writable(game_dir);
171    if created {
172        let _ = std::fs::remove_dir(&dir);
173    }
174    probed
175}
176
177/// Creates the state directory and a probe file in it.
178pub(crate) fn probe_writable(game_dir: &std::path::Path) -> Result<(), PreflightError> {
179    let not_writable = |e: std::io::Error| PreflightError::NotWritable {
180        reason: e.to_string(),
181    };
182    let dir = game_dir.join(crate::ledger::STATE_DIR);
183    std::fs::create_dir_all(&dir).map_err(not_writable)?;
184    let probe = dir.join(".probe");
185    std::fs::write(&probe, b"pacenotes").map_err(not_writable)?;
186    let _ = std::fs::remove_file(&probe);
187    Ok(())
188}
189
190#[cfg(test)]
191mod tests {
192    /// The probe runs on folders the user may abandon a moment later.
193    #[test]
194    fn a_writable_folder_is_left_as_it_was_found() {
195        let game = crate::scratch_dir::ScratchDir::new("probe-fresh");
196
197        assert_eq!(super::check_writable(&game.0), Ok(()));
198        assert!(
199            !game.0.join(crate::ledger::STATE_DIR).exists(),
200            "the probe left its directory behind"
201        );
202    }
203
204    /// A folder this installer already manages keeps everything in it.
205    #[test]
206    fn an_existing_state_directory_survives_the_probe() {
207        let game = crate::scratch_dir::ScratchDir::new("probe-existing");
208        let state = game.0.join(crate::ledger::STATE_DIR);
209        std::fs::create_dir_all(&state).expect("state dir");
210        std::fs::write(state.join("installed.json"), b"{}").expect("ledger");
211
212        assert_eq!(super::check_writable(&game.0), Ok(()));
213        assert!(state.join("installed.json").is_file());
214        assert_eq!(std::fs::read_dir(&state).expect("read").count(), 1);
215    }
216}