winbrew_app\operations\update/
metadata.rs1use 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
12pub(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
30pub(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
52pub(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
66pub(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
91pub(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}