winbrew_app\operations\update/
download.rs

1use anyhow::{Context, Result, bail};
2use std::fs::File;
3use std::io::{BufWriter, Write};
4use std::path::{Path, PathBuf};
5use zstd::stream::read::Decoder;
6
7use crate::core::network::{Client, download_url_to_temp_file};
8
9use super::metadata::{load_catalog_metadata, verify_catalog_hash};
10use super::types::CatalogDownloadPlan;
11
12/// Downloads a full catalog snapshot through the two-stage update flow.
13///
14/// The function keeps the refresh sequence strict and explicit:
15/// 1. download the metadata JSON into `metadata_temp_path`
16/// 2. parse the metadata and confirm its `current_hash` when the plan carries an expected hash
17/// 3. download the compressed catalog snapshot into a sibling `.zst` temp file
18/// 4. decompress the snapshot into `catalog_temp_path`
19/// 5. verify the final database hash against the metadata hash
20///
21/// The caller owns the final metadata and catalog temp files. This function only
22/// removes the internal compressed snapshot temp file it creates while
23/// processing the release.
24///
25/// # Errors
26/// Returns an error when the plan is not `Full`, when either download fails,
27/// when the metadata hash does not match the expected hash, when decompression
28/// fails, or when the final catalog hash check fails.
29pub(super) fn download_catalog_release<FStart, FProgress>(
30    client: &Client,
31    plan: &CatalogDownloadPlan,
32    catalog_temp_path: &Path,
33    metadata_temp_path: &Path,
34    on_start: FStart,
35    on_progress: FProgress,
36) -> Result<()>
37where
38    FStart: FnOnce(Option<u64>),
39    FProgress: FnMut(u64),
40{
41    let CatalogDownloadPlan::Full {
42        catalog_url,
43        metadata_url,
44        expected_hash,
45    } = plan
46    else {
47        bail!("download_catalog_release only supports full snapshot plans");
48    };
49
50    let compressed_catalog_temp_path = compressed_snapshot_temp_path(catalog_temp_path);
51
52    let result = (|| -> Result<()> {
53        download_url_to_temp_file(
54            client,
55            metadata_url,
56            metadata_temp_path,
57            "catalog metadata asset",
58            |_| {},
59            |_| {},
60            |_| Ok(()),
61        )?;
62
63        let metadata = load_catalog_metadata(metadata_temp_path)?;
64
65        if let Some(expected_hash) = expected_hash
66            && metadata.current_hash.as_str() != expected_hash.as_str()
67        {
68            bail!(
69                "catalog metadata hash mismatch: expected {expected_hash}, got {}",
70                metadata.current_hash
71            );
72        }
73
74        download_url_to_temp_file(
75            client,
76            catalog_url,
77            &compressed_catalog_temp_path,
78            "catalog asset",
79            on_start,
80            on_progress,
81            |_| Ok(()),
82        )?;
83
84        decompress_catalog_snapshot(&compressed_catalog_temp_path, catalog_temp_path)?;
85        verify_catalog_hash(catalog_temp_path, &metadata.current_hash)?;
86
87        Ok(())
88    })();
89
90    let _ = std::fs::remove_file(&compressed_catalog_temp_path);
91
92    result
93}
94
95fn compressed_snapshot_temp_path(catalog_temp_path: &Path) -> PathBuf {
96    let file_name = catalog_temp_path
97        .file_name()
98        .and_then(|value| value.to_str())
99        .unwrap_or("catalog.db.download");
100
101    catalog_temp_path.with_file_name(format!("{file_name}.zst"))
102}
103
104/// Decompresses a Zstandard-compressed catalog snapshot into `output_path`.
105///
106/// The file is streamed through a buffered writer and then flushed and synced so
107/// a successful return means the temporary output is fully materialized on disk.
108///
109/// # Errors
110/// Returns an error if the compressed input cannot be opened, if the decoder
111/// cannot be created, if the output file cannot be created, if decompression or
112/// flushing fails, or if the output file cannot be synced.
113fn decompress_catalog_snapshot(compressed_path: &Path, output_path: &Path) -> Result<()> {
114    let compressed_file =
115        File::open(compressed_path).context("failed to open compressed catalog snapshot")?;
116    let mut decoder = Decoder::new(compressed_file)
117        .context("failed to create zstd decoder for catalog snapshot")?;
118    let output_file =
119        File::create(output_path).context("failed to create catalog snapshot temp file")?;
120    let mut writer = BufWriter::new(output_file);
121
122    std::io::copy(&mut decoder, &mut writer).context("failed to decompress catalog snapshot")?;
123    writer.flush().context("failed to flush catalog snapshot")?;
124
125    let output_file = writer
126        .into_inner()
127        .map_err(|err| err.into_error())
128        .context("failed to finalize catalog snapshot temp file")?;
129    output_file
130        .sync_all()
131        .context("failed to sync catalog snapshot temp file")?;
132
133    Ok(())
134}
135
136#[cfg(test)]
137mod tests {
138    use super::super::types::CatalogDownloadPlan;
139    use super::{compressed_snapshot_temp_path, download_catalog_release};
140    use crate::core::network::build_client;
141    use crate::models::catalog::CatalogMetadata;
142    use std::collections::BTreeMap;
143    use tempfile::tempdir;
144    use winbrew_testing::MockServer;
145
146    #[test]
147    fn download_catalog_release_removes_internal_compressed_temp_file_on_decompression_failure() {
148        let temp_dir = tempdir().expect("temp dir");
149        let catalog_temp_path = temp_dir.path().join("catalog.db");
150        let metadata_temp_path = temp_dir.path().join("metadata.json");
151        let client = build_client("winbrew-app-tests").expect("build client");
152        let mut server = MockServer::new();
153
154        let metadata = CatalogMetadata::build_from_counts(
155            1,
156            BTreeMap::from([(String::from("winget"), 1)]),
157            String::from("sha256:expected"),
158        );
159        let metadata_url = format!("{}/metadata.json", server.url());
160        let catalog_url = format!("{}/catalog.db.zst", server.url());
161
162        let _metadata_mock = server.mock_get(
163            "/metadata.json",
164            serde_json::to_vec_pretty(&metadata).expect("serialize metadata"),
165        );
166        let _catalog_mock = server.mock_get("/catalog.db.zst", b"not valid zstd");
167
168        let plan = CatalogDownloadPlan::Full {
169            catalog_url,
170            metadata_url,
171            expected_hash: Some(String::from("sha256:expected")),
172        };
173
174        let result = download_catalog_release(
175            &client,
176            &plan,
177            &catalog_temp_path,
178            &metadata_temp_path,
179            |_| {},
180            |_| {},
181        );
182
183        let error = result.expect_err("expected decompression failure");
184
185        assert!(
186            error
187                .to_string()
188                .contains("failed to decompress catalog snapshot")
189        );
190        assert!(!compressed_snapshot_temp_path(&catalog_temp_path).exists());
191        assert!(metadata_temp_path.exists());
192    }
193}