winbrew_app\operations\update/
metadata.rs

1use anyhow::{Context, Result};
2use rusqlite::Connection;
3use std::collections::BTreeMap;
4use std::fs::File;
5use std::path::Path;
6
7use crate::core::hash::{hash_file, verify_hash};
8use crate::models::catalog::CatalogMetadata;
9use crate::models::domains::shared::HashAlgorithm;
10use url::Url;
11
12/// Tries to load the local catalog metadata file if it already exists.
13///
14/// This is the refresh-path entry point for on-disk metadata. It performs a
15/// single open attempt and only validates the file when the open succeeds,
16/// which avoids a separate existence check and the TOCTOU window that comes
17/// with it.
18///
19/// Returns `Ok(None)` when the file is missing, which is the normal cold-start
20/// case for a first refresh. Any other filesystem, parse, or validation failure
21/// is returned as an error so callers can surface the problem.
22pub(super) fn load_local_catalog_metadata(path: &Path) -> Result<Option<CatalogMetadata>> {
23    match File::open(path) {
24        Ok(file) => load_catalog_metadata_from_file(file).map(Some),
25        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
26        Err(err) => Err(err).context("failed to open local catalog metadata"),
27    }
28}
29
30/// Loads catalog metadata from a JSON file and validates the decoded payload.
31///
32/// The function expects a serialized `CatalogMetadata` document, deserializes
33/// it with `serde_json`, and then runs the model-level validation rules before
34/// returning the value to the caller.
35///
36/// Failures are reported when the file cannot be opened, when JSON decoding
37/// fails, or when the decoded metadata does not satisfy the model validation
38/// rules.
39pub(super) fn load_catalog_metadata(path: &Path) -> Result<CatalogMetadata> {
40    let file = File::open(path).context("failed to open catalog metadata download")?;
41    load_catalog_metadata_from_file(file)
42}
43
44fn load_catalog_metadata_from_file(file: File) -> Result<CatalogMetadata> {
45    let metadata: CatalogMetadata =
46        serde_json::from_reader(file).context("failed to decode catalog metadata download")?;
47    metadata.validate()?;
48
49    Ok(metadata)
50}
51
52/// Verifies that the catalog database at `path` matches `expected_hash`.
53///
54/// The file is hashed with SHA-256 and compared against the expected digest in
55/// the existing `verify_hash` format used across the workspace.
56///
57/// This is the last integrity check before the refreshed catalog is finalized,
58/// so any mismatch is treated as a hard error.
59pub(super) fn verify_catalog_hash(path: &Path, expected_hash: &str) -> Result<()> {
60    let actual_hash = hash_file(path, HashAlgorithm::Sha256)
61        .context("failed to hash downloaded catalog database")?;
62
63    verify_hash(expected_hash, actual_hash).map_err(Into::into)
64}
65
66/// Derives the metadata URL for a snapshot download URL.
67///
68/// The function parses `snapshot_url`, keeps the original scheme and host, and
69/// replaces the final path segment with `metadata.json`. For example,
70/// `https://cdn.example.invalid/releases/catalog.db.zst` becomes
71/// `https://cdn.example.invalid/releases/metadata.json`.
72///
73/// An error is returned when the input is not a valid URL or when the URL does
74/// not contain a non-empty final path segment to replace.
75pub(super) fn metadata_url_for_snapshot_url(snapshot_url: &str) -> Result<String> {
76    let mut url = Url::parse(snapshot_url).context("invalid snapshot URL")?;
77    let path = url.path();
78    let (base_path, file_name) = path
79        .rsplit_once('/')
80        .context("snapshot URL must contain a path segment")?;
81
82    if file_name.is_empty() {
83        anyhow::bail!("snapshot URL must contain a path segment");
84    }
85
86    url.set_path(&format!("{base_path}/metadata.json"));
87
88    Ok(url.to_string())
89}
90
91/// Builds validated catalog metadata from the live SQLite catalog database.
92///
93/// The function reads the total package count and the per-source breakdown from
94/// `catalog_packages`, then packages those counts together with the supplied
95/// current and previous hashes. The resulting metadata is validated before it
96/// is returned.
97///
98/// The query shape is intentionally simple and explicit: one total-count query
99/// and one grouped source-count query. That keeps the logic easy to audit and
100/// matches the schema contract used by the refresh pipeline.
101pub(super) fn build_catalog_metadata_from_connection(
102    connection: &Connection,
103    current_hash: &str,
104    previous_hash: &str,
105) -> Result<CatalogMetadata> {
106    let package_count: i64 = connection
107        .query_row("SELECT COUNT(*) FROM catalog_packages", [], |row| {
108            row.get(0)
109        })
110        .context("failed to count catalog packages")?;
111    let package_count =
112        usize::try_from(package_count).context("catalog package count does not fit in usize")?;
113
114    let mut source_counts = BTreeMap::new();
115    let mut stmt = connection
116        .prepare(
117            "SELECT source, COUNT(*) FROM catalog_packages GROUP BY source ORDER BY source ASC",
118        )
119        .context("failed to prepare catalog source count query")?;
120    let mut rows = stmt
121        .query([])
122        .context("failed to query catalog source counts")?;
123
124    while let Some(row) = rows
125        .next()
126        .context("failed to read catalog source count row")?
127    {
128        let source: String = row.get(0).context("failed to read catalog source name")?;
129        let count: i64 = row.get(1).context("failed to read catalog source count")?;
130        let count = usize::try_from(count).context("catalog source count does not fit in usize")?;
131        source_counts.insert(source, count);
132    }
133
134    let mut metadata =
135        CatalogMetadata::build_from_counts(package_count, source_counts, current_hash.to_string());
136    metadata.previous_hash = previous_hash.to_string();
137    metadata.validate()?;
138
139    Ok(metadata)
140}
141
142#[cfg(test)]
143mod tests {
144    use super::{
145        load_catalog_metadata, load_local_catalog_metadata, metadata_url_for_snapshot_url,
146        verify_catalog_hash,
147    };
148    use crate::core::hash::Hasher;
149    use crate::models::catalog::CatalogMetadata;
150    use crate::models::domains::shared::HashAlgorithm;
151    use std::collections::BTreeMap;
152    use std::fs;
153    use tempfile::tempdir;
154
155    fn sha256_hex(bytes: &[u8]) -> String {
156        let mut hasher = Hasher::new(HashAlgorithm::Sha256);
157        hasher.update(bytes);
158
159        hasher
160            .finalize()
161            .iter()
162            .map(|byte| format!("{byte:02x}"))
163            .collect()
164    }
165
166    #[test]
167    fn load_local_catalog_metadata_returns_none_when_file_is_missing() {
168        let temp_dir = tempdir().expect("temp dir");
169        let path = temp_dir.path().join("metadata.json");
170
171        let loaded = load_local_catalog_metadata(&path).expect("load local metadata");
172
173        assert!(loaded.is_none());
174    }
175
176    #[test]
177    fn load_local_catalog_metadata_rejects_invalid_json() {
178        let temp_dir = tempdir().expect("temp dir");
179        let path = temp_dir.path().join("metadata.json");
180
181        fs::write(&path, b"not valid json").expect("write invalid metadata");
182
183        let err = load_local_catalog_metadata(&path).expect_err("invalid metadata should fail");
184
185        assert!(
186            err.to_string()
187                .contains("failed to decode catalog metadata download")
188        );
189    }
190
191    #[test]
192    fn metadata_url_for_snapshot_url_rejects_missing_path_segment() {
193        let err = metadata_url_for_snapshot_url("https://cdn.example.invalid")
194            .expect_err("snapshot url without path should fail");
195
196        assert!(
197            err.to_string()
198                .contains("snapshot URL must contain a path segment")
199        );
200    }
201
202    #[test]
203    fn load_catalog_metadata_reads_valid_metadata() {
204        let temp_dir = tempdir().expect("temp dir");
205        let path = temp_dir.path().join("metadata.json");
206        let metadata = CatalogMetadata::build_from_counts(
207            2,
208            BTreeMap::from([(String::from("scoop"), 1)]),
209            String::from("sha256:abc"),
210        );
211
212        fs::write(
213            &path,
214            serde_json::to_vec_pretty(&metadata).expect("serialize metadata"),
215        )
216        .expect("write metadata");
217
218        let loaded = load_catalog_metadata(&path).expect("load metadata");
219
220        assert_eq!(loaded.current_hash, metadata.current_hash);
221        assert_eq!(loaded.package_count, metadata.package_count);
222        assert_eq!(loaded.source_counts.get("scoop"), Some(&1));
223    }
224
225    #[test]
226    fn verify_catalog_hash_accepts_matching_hash() {
227        let temp_dir = tempdir().expect("temp dir");
228        let path = temp_dir.path().join("catalog.db");
229        let contents = b"catalog-bytes";
230
231        fs::write(&path, contents).expect("write catalog");
232
233        let expected_hash = format!("sha256:{}", sha256_hex(contents));
234
235        verify_catalog_hash(&path, &expected_hash).expect("hash should match");
236    }
237
238    #[test]
239    fn verify_catalog_hash_rejects_mismatch() {
240        let temp_dir = tempdir().expect("temp dir");
241        let path = temp_dir.path().join("catalog.db");
242
243        fs::write(&path, b"catalog-bytes").expect("write catalog");
244
245        let err = verify_catalog_hash(
246            &path,
247            "sha256:0000000000000000000000000000000000000000000000000000000000000000",
248        )
249        .expect_err("hash mismatch should fail");
250
251        assert!(err.to_string().contains("checksum mismatch"));
252    }
253
254    #[test]
255    fn metadata_url_is_derived_from_snapshot_url() {
256        assert_eq!(
257            metadata_url_for_snapshot_url("https://cdn.example.invalid/releases/catalog.db.zst")
258                .expect("metadata url should be derived"),
259            "https://cdn.example.invalid/releases/metadata.json"
260        );
261    }
262}