Skip to main content

soar_registry/
metadata.rs

1//! Metadata fetching and processing for package repositories.
2//!
3//! This module provides functions for fetching package metadata from remote
4//! repositories, handling both SQLite database and JSON formats.
5
6use 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
30/// Magic bytes for SQLite database files.
31pub const SQLITE_MAGIC_BYTES: [u8; 4] = [0x53, 0x51, 0x4c, 0x69];
32
33/// Magic bytes for Zstandard compressed files.
34pub const ZST_MAGIC_BYTES: [u8; 4] = [0x28, 0xb5, 0x2f, 0xfd];
35
36/// Maximum size, in bytes, allowed for metadata in either form.
37///
38/// This bounds both the downloaded body (which would otherwise be capped at
39/// ureq's 10 MB default, truncating a large catalog) and the zstd-decompressed
40/// output (so a decompression bomb cannot exhaust the disk). 256 MB leaves ample
41/// headroom for catalog growth while keeping a malicious response bounded.
42pub const MAX_METADATA_SIZE: u64 = 256 * 1024 * 1024;
43
44/// Represents the processed content of fetched metadata.
45///
46/// Metadata from repositories can come in two formats:
47/// - Pre-built SQLite databases (more efficient for large repositories)
48/// - JSON arrays of packages (simpler format)
49///
50/// The caller is responsible for handling each variant appropriately,
51/// typically by either writing the SQLite bytes directly to disk or
52/// importing JSON packages into a new database.
53pub enum MetadataContent {
54    /// Raw SQLite database bytes, ready to be written to disk.
55    SqliteDb(Vec<u8>),
56    /// Parsed package metadata from JSON format.
57    Json(Vec<RemotePackage>),
58}
59
60/// Fetches repository metadata from a remote source.
61///
62/// This function retrieves package metadata for a configured repository, handling
63/// caching via ETags and respecting the repository's sync interval.
64///
65/// # Arguments
66///
67/// * `repo` - The repository configuration
68/// * `force` - If `true`, bypasses cache validation and fetches fresh metadata
69/// * `existing_etag` - Optional etag from a previous fetch, read from the database
70///
71/// # Returns
72///
73/// * `Ok(Some((etag, content)))` - New metadata was fetched successfully
74/// * `Ok(None)` - Cached metadata is still valid (not modified)
75/// * `Err(_)` - An error occurred during fetching or processing
76///
77/// # Errors
78///
79/// Returns [`RegistryError`] if:
80/// - The repository URL is invalid
81/// - Network request fails
82/// - Server returns an error response
83/// - Response is missing required ETag header
84/// - Metadata content cannot be processed
85/// - Public key fetch fails (if configured)
86///
87/// # Example
88///
89/// ```no_run
90/// use soar_registry::{fetch_metadata, MetadataContent, write_metadata_db};
91/// use soar_config::repository::Repository;
92///
93/// async fn sync(repo: &Repository, etag: Option<String>) -> soar_registry::Result<()> {
94///     if let Some((new_etag, content)) = fetch_metadata(repo, false, etag).await? {
95///         let db_path = repo.get_path().unwrap().join("metadata.db");
96///         if let MetadataContent::SqliteDb(bytes) = content {
97///             write_metadata_db(&bytes, &db_path)?;
98///         }
99///     }
100///     Ok(())
101/// }
102/// ```
103pub 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    // A repository URL can point at a local file (`file://` or a filesystem
145    // path) or a remote http(s) endpoint. Local sources are read from disk;
146    // remote sources are fetched over HTTP.
147    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
211/// Resolves a repository URL to a local filesystem path when it is a local
212/// source (a `file://` URL or a filesystem path), or `None` for http(s) URLs.
213fn 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
228/// Validates the scheme of a remote metadata URL.
229///
230/// `https` is always allowed. Cleartext `http` is only allowed when the metadata
231/// will be authenticated by signature verification, so a network attacker cannot
232/// substitute unverifiable metadata. Any other scheme is rejected.
233fn 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
246/// Reads and verifies repository metadata from a local file.
247///
248/// Uses the file modification time as the change-detection token so an unchanged
249/// file returns `Ok(None)` on subsequent syncs, mirroring the ETag behaviour of
250/// the remote path.
251fn 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
290/// Reads the detached signature published next to a local metadata file.
291fn 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
298/// Verifies the authenticity of fetched metadata against the repository pubkey.
299///
300/// When the repository has signature verification enabled, this loads the
301/// detached minisign signature published next to the metadata (`<url>.sig`, over
302/// HTTP or from disk depending on the source) and verifies it over the raw
303/// fetched bytes, before the metadata is decompressed, parsed, or persisted. A
304/// missing or invalid signature is a hard error so a tampered metadata source
305/// cannot supply both the package `download_url` and its expected checksum.
306fn 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
355/// Fetches the textual contents of a detached minisign signature.
356fn 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
373/// Processes raw metadata content and determines its format.
374///
375/// This function inspects the magic bytes of the content to determine whether
376/// it's a SQLite database, zstd-compressed data, or JSON. Compressed content
377/// is automatically decompressed.
378///
379/// # Arguments
380///
381/// * `content` - Raw bytes fetched from the remote source
382/// * `metadata_db_path` - Path used for creating temporary files during decompression
383///
384/// # Returns
385///
386/// Returns [`MetadataContent::SqliteDb`] if the content is (or decompresses to)
387/// a SQLite database, or [`MetadataContent::Json`] if it's JSON data.
388///
389/// # Errors
390///
391/// Returns [`RegistryError`] if:
392/// - Content is less than 4 bytes (too short to identify)
393/// - Zstd decompression fails
394/// - JSON parsing fails
395/// - Temporary file operations fail
396pub 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
452/// The highest index format this build understands.
453pub const SUPPORTED_FORMAT: u32 = 1;
454
455/// The versioned shape of a metadata index.
456///
457/// The original shape is a bare array of packages; this one wraps it so a
458/// client can tell an index it cannot read from one that merely lacks a field.
459#[derive(Deserialize)]
460struct VersionedIndex {
461    format: u32,
462    packages: Vec<RemotePackage>,
463}
464
465impl VersionedIndex {
466    /// Unwrap to the packages, refusing an index newer than this build.
467    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
478/// Whether an index is the versioned shape, judged by its opening character.
479///
480/// The two are told apart here rather than by an untagged enum, which reports
481/// only that no variant matched and so turns one malformed field anywhere in
482/// the index into a message that names nothing.
483fn 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
490/// Parse an index in either shape, refusing one newer than this build.
491pub 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
499/// Parse an index that is still on disk, without holding it twice in memory.
500fn 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
514/// Writes SQLite database content to a file.
515///
516/// This is a convenience function for writing [`MetadataContent::SqliteDb`]
517/// bytes to disk using buffered I/O.
518///
519/// # Arguments
520///
521/// * `content` - Raw SQLite database bytes
522/// * `path` - Destination file path
523///
524/// # Errors
525///
526/// Returns [`RegistryError::IoError`] if file creation or writing fails.
527///
528/// # Example
529///
530/// ```no_run
531/// use soar_registry::write_metadata_db;
532///
533/// fn save_db(bytes: &[u8]) -> soar_registry::Result<()> {
534///     write_metadata_db(bytes, "/path/to/metadata.db")
535/// }
536/// ```
537pub 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}