1use std::{
7 fs::{self, File},
8 io::{self, BufRead, BufReader, BufWriter, Write},
9 path::{Path, PathBuf},
10 time::UNIX_EPOCH,
11};
12
13use minisign_verify::{PublicKey, Signature};
14use serde::Deserialize;
15use soar_config::repository::Repository;
16use soar_dl::http_client::SHARED_AGENT;
17use soar_utils::path::resolve_path;
18use tracing::{debug, warn};
19use ureq::http::{
20 header::{CACHE_CONTROL, ETAG, IF_NONE_MATCH, PRAGMA},
21 StatusCode,
22};
23use url::Url;
24
25use crate::{
26 error::{ErrorContext, RegistryError, Result},
27 package::RemotePackage,
28};
29
30pub const SQLITE_MAGIC_BYTES: [u8; 4] = [0x53, 0x51, 0x4c, 0x69];
32
33pub const ZST_MAGIC_BYTES: [u8; 4] = [0x28, 0xb5, 0x2f, 0xfd];
35
36pub const MAX_METADATA_SIZE: u64 = 256 * 1024 * 1024;
43
44pub enum MetadataContent {
54 SqliteDb(Vec<u8>),
56 Json(Vec<RemotePackage>),
58}
59
60pub async fn fetch_metadata(
104 repo: &Repository,
105 force: bool,
106 existing_etag: Option<String>,
107) -> Result<Option<(String, MetadataContent)>> {
108 let repo_path = repo.get_path().map_err(|e| {
109 RegistryError::IoError {
110 action: "getting repository path".to_string(),
111 source: io::Error::other(e.to_string()),
112 }
113 })?;
114 let metadata_db = repo_path.join("metadata.db");
115
116 if !metadata_db.exists() {
117 fs::create_dir_all(&repo_path)
118 .with_context(|| format!("creating directory {}", repo_path.display()))?;
119 }
120
121 let sync_interval = repo.sync_interval();
122
123 if metadata_db.exists() && !force {
124 if sync_interval == u128::MAX {
125 return Ok(None);
126 }
127
128 let file_info = metadata_db
129 .metadata()
130 .with_context(|| format!("reading file metadata from {}", metadata_db.display()))?;
131 if let Ok(modified) = file_info.modified() {
132 if sync_interval >= modified.elapsed()?.as_millis() {
133 return Ok(None);
134 }
135 }
136 }
137
138 let etag = if metadata_db.exists() {
139 existing_etag.unwrap_or_default()
140 } else {
141 String::new()
142 };
143
144 if let Some(path) = local_metadata_path(&repo.url) {
148 return fetch_local_metadata(repo, &path, &metadata_db, &etag, force);
149 }
150
151 let parsed_url =
152 Url::parse(&repo.url).map_err(|err| RegistryError::InvalidUrl(err.to_string()))?;
153 ensure_remote_scheme_allowed(
154 &repo.url,
155 parsed_url.scheme(),
156 repo.signature_verification(),
157 )?;
158 if parsed_url.scheme() == "http" {
159 warn!(
160 "repository '{}' fetches metadata over insecure http; authenticity relies on signature verification",
161 repo.name
162 );
163 }
164
165 let mut req = SHARED_AGENT
166 .get(&repo.url)
167 .header(CACHE_CONTROL, "no-cache")
168 .header(PRAGMA, "no-cache");
169
170 if !etag.is_empty() {
171 req = req.header(IF_NONE_MATCH, etag);
172 }
173
174 let resp = req
175 .call()
176 .map_err(|err| RegistryError::FailedToFetchRemote(err.to_string()))?;
177
178 if resp.status() == StatusCode::NOT_MODIFIED {
179 return Ok(None);
180 }
181
182 if !resp.status().is_success() {
183 let msg = format!("{} [{}]", repo.url, resp.status());
184 return Err(RegistryError::FailedToFetchRemote(msg));
185 }
186
187 let etag = resp
188 .headers()
189 .get(ETAG)
190 .and_then(|h| h.to_str().ok())
191 .map(String::from)
192 .ok_or(RegistryError::MissingEtag)?;
193
194 debug!("Fetching metadata from {}", repo.url);
195
196 let content = resp
197 .into_body()
198 .into_with_config()
199 .limit(MAX_METADATA_SIZE)
200 .read_to_vec()?;
201
202 verify_metadata_signature(repo, &content, || {
203 fetch_signature_text(&format!("{}.sig", repo.url))
204 })?;
205
206 let metadata_content = process_metadata_content(content, &metadata_db)?;
207
208 Ok(Some((etag, metadata_content)))
209}
210
211fn local_metadata_path(url: &str) -> Option<PathBuf> {
214 let trimmed = url.trim();
215 if let Some(rest) = trimmed.strip_prefix("file://") {
216 return resolve_path(rest).ok();
217 }
218 if trimmed.starts_with('/')
219 || trimmed.starts_with('~')
220 || trimmed.starts_with('.')
221 || trimmed.starts_with('$')
222 {
223 return resolve_path(trimmed).ok();
224 }
225 None
226}
227
228fn ensure_remote_scheme_allowed(url: &str, scheme: &str, signature_verified: bool) -> Result<()> {
234 match scheme {
235 "https" => Ok(()),
236 "http" if signature_verified => Ok(()),
237 "http" => Err(RegistryError::InsecureUrl(format!(
238 "{url}: http metadata is only allowed when signature verification is enabled with a configured pubkey"
239 ))),
240 _ => Err(RegistryError::InsecureUrl(format!(
241 "{url}: metadata must be served over https"
242 ))),
243 }
244}
245
246fn fetch_local_metadata(
252 repo: &Repository,
253 path: &Path,
254 metadata_db: &Path,
255 existing_etag: &str,
256 force: bool,
257) -> Result<Option<(String, MetadataContent)>> {
258 let file_info =
259 fs::metadata(path).with_context(|| format!("reading metadata file {}", path.display()))?;
260
261 let mtime_tag = file_info
262 .modified()
263 .ok()
264 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
265 .map(|d| d.as_millis().to_string())
266 .unwrap_or_default();
267
268 if !force && !mtime_tag.is_empty() && existing_etag == mtime_tag {
269 return Ok(None);
270 }
271
272 if file_info.len() > MAX_METADATA_SIZE {
273 return Err(RegistryError::MetadataTooLarge {
274 limit: MAX_METADATA_SIZE,
275 });
276 }
277
278 debug!("Reading metadata from {}", path.display());
279
280 let content =
281 fs::read(path).with_context(|| format!("reading metadata file {}", path.display()))?;
282
283 verify_metadata_signature(repo, &content, || read_local_signature(path))?;
284
285 let metadata_content = process_metadata_content(content, metadata_db)?;
286
287 Ok(Some((mtime_tag, metadata_content)))
288}
289
290fn read_local_signature(metadata_path: &Path) -> std::result::Result<String, String> {
292 let mut sig_path = metadata_path.as_os_str().to_os_string();
293 sig_path.push(".sig");
294 let sig_path = PathBuf::from(sig_path);
295 fs::read_to_string(&sig_path).map_err(|err| format!("{}: {err}", sig_path.display()))
296}
297
298fn verify_metadata_signature(
307 repo: &Repository,
308 content: &[u8],
309 load_signature: impl FnOnce() -> std::result::Result<String, String>,
310) -> Result<()> {
311 if !repo.signature_verification() {
312 return Ok(());
313 }
314
315 let pubkey = repo.pubkey.as_deref().ok_or_else(|| {
316 RegistryError::MetadataSignatureInvalid {
317 repo: repo.name.clone(),
318 reason: "signature verification is enabled but no public key is configured".to_string(),
319 }
320 })?;
321
322 let sig_text = load_signature().map_err(|reason| {
323 RegistryError::MetadataSignatureMissing {
324 repo: repo.name.clone(),
325 reason,
326 }
327 })?;
328
329 let public_key = PublicKey::from_base64(pubkey.trim()).map_err(|err| {
330 RegistryError::MetadataSignatureInvalid {
331 repo: repo.name.clone(),
332 reason: format!("invalid public key: {err}"),
333 }
334 })?;
335 let signature = Signature::decode(&sig_text).map_err(|err| {
336 RegistryError::MetadataSignatureInvalid {
337 repo: repo.name.clone(),
338 reason: format!("malformed signature: {err}"),
339 }
340 })?;
341
342 public_key
343 .verify(content, &signature, true)
344 .map_err(|err| {
345 RegistryError::MetadataSignatureInvalid {
346 repo: repo.name.clone(),
347 reason: err.to_string(),
348 }
349 })?;
350
351 debug!("Verified metadata signature for {}", repo.name);
352 Ok(())
353}
354
355fn fetch_signature_text(url: &str) -> std::result::Result<String, String> {
357 let resp = SHARED_AGENT
358 .get(url)
359 .header(CACHE_CONTROL, "no-cache")
360 .header(PRAGMA, "no-cache")
361 .call()
362 .map_err(|err| err.to_string())?;
363
364 if !resp.status().is_success() {
365 return Err(format!("{} [{}]", url, resp.status()));
366 }
367
368 resp.into_body()
369 .read_to_string()
370 .map_err(|err| err.to_string())
371}
372
373pub fn process_metadata_content(
397 content: Vec<u8>,
398 metadata_db_path: &Path,
399) -> Result<MetadataContent> {
400 if content.len() < 4 {
401 return Err(RegistryError::MetadataTooShort);
402 }
403
404 if content[..4] == ZST_MAGIC_BYTES {
405 let tmp_path = format!("{}.part", metadata_db_path.display());
406 let mut tmp_file = File::create(&tmp_path)
407 .with_context(|| format!("creating temporary file {tmp_path}"))?;
408
409 let decoder = zstd::Decoder::new(content.as_slice())
410 .map_err(|e| RegistryError::Custom(format!("creating zstd decoder: {e}")))?;
411 let mut limited = io::Read::take(decoder, MAX_METADATA_SIZE + 1);
412 let written = io::copy(&mut limited, &mut tmp_file)
413 .with_context(|| format!("decoding zstd from {tmp_path}"))?;
414 if written > MAX_METADATA_SIZE {
415 drop(tmp_file);
416 let _ = fs::remove_file(&tmp_path);
417 return Err(RegistryError::MetadataTooLarge {
418 limit: MAX_METADATA_SIZE,
419 });
420 }
421
422 let magic_bytes = soar_utils::fs::read_file_signature(&tmp_path, 4).map_err(|e| {
423 RegistryError::IoError {
424 action: format!("reading signature from {tmp_path}"),
425 source: io::Error::other(e.to_string()),
426 }
427 })?;
428
429 if magic_bytes == SQLITE_MAGIC_BYTES {
430 let db_content = fs::read(&tmp_path)
431 .with_context(|| format!("reading temporary file {tmp_path}"))?;
432 fs::remove_file(&tmp_path)
433 .with_context(|| format!("removing temporary file {tmp_path}"))?;
434 Ok(MetadataContent::SqliteDb(db_content))
435 } else {
436 let tmp_file = File::open(&tmp_path)
437 .with_context(|| format!("opening temporary file {tmp_path}"))?;
438 let reader = BufReader::new(tmp_file);
439 let metadata = parse_index_reader(reader)?;
440 fs::remove_file(&tmp_path)
441 .with_context(|| format!("removing temporary file {tmp_path}"))?;
442 Ok(MetadataContent::Json(metadata))
443 }
444 } else if content[..4] == SQLITE_MAGIC_BYTES {
445 Ok(MetadataContent::SqliteDb(content))
446 } else {
447 let metadata = parse_index(&content)?;
448 Ok(MetadataContent::Json(metadata))
449 }
450}
451
452pub const SUPPORTED_FORMAT: u32 = 1;
454
455#[derive(Deserialize)]
460struct VersionedIndex {
461 format: u32,
462 packages: Vec<RemotePackage>,
463}
464
465impl VersionedIndex {
466 fn into_packages(self) -> Result<Vec<RemotePackage>> {
468 if self.format > SUPPORTED_FORMAT {
469 return Err(RegistryError::UnsupportedFormat {
470 found: self.format,
471 supported: SUPPORTED_FORMAT,
472 });
473 }
474 Ok(self.packages)
475 }
476}
477
478fn is_versioned(bytes: &[u8]) -> bool {
484 bytes
485 .iter()
486 .find(|b| !b.is_ascii_whitespace())
487 .is_some_and(|b| *b == b'{')
488}
489
490pub fn parse_index(bytes: &[u8]) -> Result<Vec<RemotePackage>> {
492 if is_versioned(bytes) {
493 serde_json::from_slice::<VersionedIndex>(bytes)?.into_packages()
494 } else {
495 Ok(serde_json::from_slice(bytes)?)
496 }
497}
498
499fn parse_index_reader(mut reader: impl BufRead) -> Result<Vec<RemotePackage>> {
501 let versioned = is_versioned(reader.fill_buf().map_err(|e| {
502 RegistryError::IoError {
503 action: "reading metadata".to_string(),
504 source: e,
505 }
506 })?);
507 if versioned {
508 serde_json::from_reader::<_, VersionedIndex>(reader)?.into_packages()
509 } else {
510 Ok(serde_json::from_reader(reader)?)
511 }
512}
513
514pub fn write_metadata_db<P: AsRef<Path>>(content: &[u8], path: P) -> Result<()> {
538 let path = path.as_ref();
539 let mut writer = BufWriter::new(
540 File::create(path).with_context(|| format!("creating metadata file {}", path.display()))?,
541 );
542 writer
543 .write_all(content)
544 .with_context(|| format!("writing to metadata file {}", path.display()))?;
545 Ok(())
546}
547
548#[cfg(test)]
549mod tests {
550 use super::*;
551
552 #[test]
553 fn remote_urls_are_not_local() {
554 assert!(local_metadata_path("https://example.com/metadata.sdb.zstd").is_none());
555 assert!(local_metadata_path("http://example.com/metadata.sdb.zstd").is_none());
556 }
557
558 #[test]
559 fn file_scheme_and_paths_are_local() {
560 assert_eq!(
561 local_metadata_path("file:///tmp/metadata.sdb.zstd"),
562 Some(PathBuf::from("/tmp/metadata.sdb.zstd"))
563 );
564 assert_eq!(
565 local_metadata_path("/srv/repo/metadata.sdb.zstd"),
566 Some(PathBuf::from("/srv/repo/metadata.sdb.zstd"))
567 );
568 }
569
570 #[test]
571 fn https_is_always_allowed() {
572 assert!(ensure_remote_scheme_allowed("https://x/m.sdb", "https", false).is_ok());
573 assert!(ensure_remote_scheme_allowed("https://x/m.sdb", "https", true).is_ok());
574 }
575
576 #[test]
577 fn http_requires_signature_verification() {
578 assert!(ensure_remote_scheme_allowed("http://x/m.sdb", "http", true).is_ok());
579 assert!(matches!(
580 ensure_remote_scheme_allowed("http://x/m.sdb", "http", false),
581 Err(RegistryError::InsecureUrl(_))
582 ));
583 }
584
585 #[test]
586 fn unknown_schemes_are_rejected() {
587 assert!(matches!(
588 ensure_remote_scheme_allowed("ftp://x/m.sdb", "ftp", true),
589 Err(RegistryError::InsecureUrl(_))
590 ));
591 }
592}