Skip to main content

appcore_update/
catalog_store.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: catalog_store.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/09/25 00:00:00 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/09/25 00:00:00 by dnettoRaw
8//      ###########      S: 1.0.3-rc
9// =============================================================================
10
11//! Bounded release catalog with safe local artifact locations.
12
13use crate::catalog::{ReleaseCatalog, RELEASE_CATALOG_MAX_BYTES, RELEASE_CATALOG_MAX_ENTRIES};
14use crate::filesystem::{open_regular_file, read_regular_file_bounded};
15use crate::stream::{ArtifactSource, DEFAULT_ARTIFACT_CHUNK_BYTES};
16use crate::{ArtifactAuthenticityVerifier, ArtifactDescriptor, UpdateError, UpdateResult};
17use serde::{Deserialize, Serialize};
18use std::collections::BTreeMap;
19use std::io::{Read, Seek, SeekFrom};
20use std::path::{Component, Path, PathBuf};
21
22/// One signed descriptor and its location relative to the catalog root.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct CatalogEntry {
25    /// Signed immutable artifact descriptor.
26    pub descriptor: ArtifactDescriptor,
27    /// Safe relative path containing the immutable artifact bytes.
28    pub relative_path: String,
29}
30
31/// Persistent catalog that serves bytes only for catalog-owned descriptors.
32#[derive(Debug, Clone)]
33pub struct ReleaseCatalogStore {
34    root: PathBuf,
35    catalog: ReleaseCatalog,
36    entries: BTreeMap<String, CatalogEntry>,
37}
38
39impl ReleaseCatalogStore {
40    /// Opens and validates a catalog below an existing controlled directory.
41    pub fn open(
42        root: impl AsRef<Path>,
43        catalog_path: impl AsRef<Path>,
44        verifier: &dyn ArtifactAuthenticityVerifier,
45    ) -> UpdateResult<Self> {
46        let root = canonical_directory(root.as_ref())?;
47        let catalog_path = resolve_safe_path(&root, catalog_path.as_ref())?;
48        let bytes = read_regular_file_bounded(&catalog_path, RELEASE_CATALOG_MAX_BYTES)
49            .map_err(|error| UpdateError::Store(error.to_string()))?;
50        let entries: Vec<CatalogEntry> = serde_json::from_slice(&bytes)
51            .map_err(|error| UpdateError::Store(error.to_string()))?;
52        if entries.len() > RELEASE_CATALOG_MAX_ENTRIES {
53            return Err(UpdateError::Store(
54                "release catalog exceeds entry limit".to_string(),
55            ));
56        }
57        let descriptors = entries
58            .iter()
59            .map(|entry| {
60                validate_relative_path(&entry.relative_path)?;
61                resolve_safe_path(&root, Path::new(&entry.relative_path))?;
62                Ok(entry.descriptor.clone())
63            })
64            .collect::<UpdateResult<Vec<_>>>()?;
65        let catalog = ReleaseCatalog::from_entries(descriptors, verifier)?;
66        let mut indexed = BTreeMap::new();
67        for entry in entries {
68            if indexed
69                .insert(entry.descriptor.sha256().to_string(), entry)
70                .is_some()
71            {
72                return Err(UpdateError::InvalidArtifact(
73                    "catalog contains ambiguous duplicate artifact hashes".to_string(),
74                ));
75            }
76        }
77        Ok(Self {
78            root,
79            catalog,
80            entries: indexed,
81        })
82    }
83
84    /// Returns the validated release catalog for version selection.
85    pub fn catalog(&self) -> &ReleaseCatalog {
86        &self.catalog
87    }
88
89    /// Returns the number of descriptor/location entries.
90    pub fn len(&self) -> usize {
91        self.entries.len()
92    }
93
94    /// Returns whether the store contains no entries.
95    pub fn is_empty(&self) -> bool {
96        self.entries.is_empty()
97    }
98
99    fn entry_for(&self, descriptor: &ArtifactDescriptor) -> UpdateResult<&CatalogEntry> {
100        let entry = self
101            .entries
102            .get(descriptor.sha256())
103            .filter(|entry| entry.descriptor == *descriptor)
104            .ok_or_else(|| {
105                UpdateError::Provider(
106                    "artifact descriptor is not present in the release catalog".to_string(),
107                )
108            })?;
109        Ok(entry)
110    }
111}
112
113impl ArtifactSource for ReleaseCatalogStore {
114    fn read_chunk(
115        &self,
116        descriptor: &ArtifactDescriptor,
117        offset: u64,
118        max_len: usize,
119    ) -> UpdateResult<Vec<u8>> {
120        if max_len == 0 || max_len > DEFAULT_ARTIFACT_CHUNK_BYTES {
121            return Err(UpdateError::Transfer(
122                "requested chunk exceeds the catalog source limit".to_string(),
123            ));
124        }
125        if offset >= descriptor.size_bytes() {
126            return Err(UpdateError::Transfer(
127                "artifact offset is outside the declared size".to_string(),
128            ));
129        }
130        let entry = self.entry_for(descriptor)?;
131        let path = resolve_safe_path(&self.root, Path::new(&entry.relative_path))?;
132        let mut file =
133            open_regular_file(&path).map_err(|error| UpdateError::Provider(error.to_string()))?;
134        if file
135            .metadata()
136            .map_err(|error| UpdateError::Provider(error.to_string()))?
137            .len()
138            != descriptor.size_bytes()
139        {
140            return Err(UpdateError::ChecksumMismatch);
141        }
142        file.seek(SeekFrom::Start(offset))
143            .map_err(|error| UpdateError::Provider(error.to_string()))?;
144        let remaining = descriptor.size_bytes() - offset;
145        let capacity = usize::try_from(remaining).unwrap_or(max_len).min(max_len);
146        let mut bytes = vec![0_u8; capacity];
147        file.read_exact(&mut bytes)
148            .map_err(|error| UpdateError::Provider(error.to_string()))?;
149        Ok(bytes)
150    }
151}
152
153fn canonical_directory(path: &Path) -> UpdateResult<PathBuf> {
154    let metadata =
155        std::fs::symlink_metadata(path).map_err(|error| UpdateError::Store(error.to_string()))?;
156    if metadata.file_type().is_symlink() || !metadata.is_dir() {
157        return Err(UpdateError::Store(
158            "catalog root is not a regular directory".to_string(),
159        ));
160    }
161    std::fs::canonicalize(path).map_err(|error| UpdateError::Store(error.to_string()))
162}
163
164fn resolve_safe_path(root: &Path, relative: &Path) -> UpdateResult<PathBuf> {
165    validate_relative_path(relative.to_string_lossy().as_ref())?;
166    let candidate = root.join(relative);
167    let canonical =
168        std::fs::canonicalize(&candidate).map_err(|error| UpdateError::Store(error.to_string()))?;
169    if !canonical.starts_with(root) {
170        return Err(UpdateError::Store(
171            "catalog path escapes its controlled directory".to_string(),
172        ));
173    }
174    Ok(canonical)
175}
176
177fn validate_relative_path(value: &str) -> UpdateResult<()> {
178    let path = Path::new(value);
179    if value.trim().is_empty() || value.len() > 1_024 || value.chars().any(char::is_control) {
180        return Err(UpdateError::InvalidArtifact(
181            "catalog relative path is invalid".to_string(),
182        ));
183    }
184    if path.is_absolute()
185        || path.components().any(|component| {
186            matches!(
187                component,
188                Component::Prefix(_)
189                    | Component::RootDir
190                    | Component::ParentDir
191                    | Component::CurDir
192            )
193        })
194    {
195        return Err(UpdateError::InvalidArtifact(
196            "catalog relative path must stay below its root".to_string(),
197        ));
198    }
199    Ok(())
200}