Skip to main content

appcore_update/
catalog.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: catalog.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/09/24 00:00:00 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/09/24 00:00:00 by dnettoRaw
8//      ###########      S: 1.0.3-rc
9// =============================================================================
10
11//! Bounded multi-platform release catalog and deterministic selection.
12
13use crate::{
14    ArtifactAuthenticityVerifier, ArtifactDescriptor, QuarantineKey, QuarantineReason,
15    QuarantineStore, UpdateError, UpdateResult,
16};
17use appcore_contracts::ApplicationId;
18use semver::Version;
19use serde::{Deserialize, Serialize};
20use std::collections::{BTreeMap, BTreeSet};
21use std::io::{BufReader, Read};
22use std::time::{SystemTime, UNIX_EPOCH};
23
24/// Maximum encoded catalog size accepted by the built-in reader.
25pub const RELEASE_CATALOG_MAX_BYTES: usize = 4 * 1024 * 1024;
26/// Maximum number of entries retained by one catalog.
27pub const RELEASE_CATALOG_MAX_ENTRIES: usize = 4_096;
28
29/// Local identity used to select one compatible release.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct UpdateIdentity {
32    /// Application identity.
33    pub application_id: ApplicationId,
34    /// Target operating system.
35    pub os: String,
36    /// Target CPU architecture.
37    pub architecture: String,
38    /// Host installation format.
39    pub format: String,
40    /// Requested update channel.
41    pub channel: String,
42    /// Currently installed semantic version.
43    pub current_version: String,
44}
45
46/// Query used to select the latest release compatible with a Runtime host.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct LatestCompatibleUpdateIdentity {
49    /// Application identity.
50    pub application_id: ApplicationId,
51    /// Target operating system.
52    pub os: String,
53    /// Target CPU architecture.
54    pub architecture: String,
55    /// Host installation format.
56    pub format: String,
57    /// Requested update channel.
58    pub channel: String,
59    /// Currently installed semantic version.
60    pub current_version: String,
61    /// Runtime semantic version available on the host.
62    pub runtime_version: String,
63    /// Application/runtime protocol available on the host.
64    pub protocol_version: String,
65}
66
67/// One catalog release excluded by active quarantine.
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub struct QuarantineExclusion {
70    /// Release identity excluded from automatic selection.
71    pub key: QuarantineKey,
72    /// Reason recorded when the release was quarantined.
73    pub reason: QuarantineReason,
74    /// Exclusive expiry timestamp, when configured.
75    pub expires_at_ms: Option<u64>,
76}
77
78/// Selection result with bounded quarantine explanations.
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct QuarantineSelection {
81    /// Selected release, when one remains eligible.
82    pub selected: Option<ArtifactDescriptor>,
83    /// Releases skipped because their quarantine is active.
84    pub exclusions: Vec<QuarantineExclusion>,
85}
86
87/// Validated catalog of signed, platform-specific artifact descriptors.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct ReleaseCatalog {
90    entries: Vec<ArtifactDescriptor>,
91}
92
93impl ReleaseCatalog {
94    /// Validates and opens a bounded catalog from JSON.
95    pub fn open<R: Read>(
96        reader: R,
97        verifier: &dyn ArtifactAuthenticityVerifier,
98    ) -> UpdateResult<Self> {
99        let mut reader = BufReader::new(reader.take(RELEASE_CATALOG_MAX_BYTES as u64 + 1));
100        let mut bytes = Vec::with_capacity(RELEASE_CATALOG_MAX_BYTES.min(64 * 1024));
101        reader
102            .read_to_end(&mut bytes)
103            .map_err(|error| UpdateError::Provider(error.to_string()))?;
104        if bytes.len() > RELEASE_CATALOG_MAX_BYTES {
105            return Err(UpdateError::Provider(
106                "release catalog exceeds configured read limit".to_string(),
107            ));
108        }
109        let entries: Vec<ArtifactDescriptor> = serde_json::from_slice(&bytes)
110            .map_err(|error| UpdateError::Provider(error.to_string()))?;
111        Self::from_entries(entries, verifier)
112    }
113
114    /// Validates an owned catalog and all of its signatures.
115    pub fn from_entries(
116        entries: Vec<ArtifactDescriptor>,
117        verifier: &dyn ArtifactAuthenticityVerifier,
118    ) -> UpdateResult<Self> {
119        if entries.len() > RELEASE_CATALOG_MAX_ENTRIES {
120            return Err(UpdateError::Provider(
121                "release catalog exceeds entry limit".to_string(),
122            ));
123        }
124        let mut keys = BTreeSet::new();
125        for entry in &entries {
126            entry.validate()?;
127            let target = entry.target().ok_or_else(|| {
128                UpdateError::InvalidArtifact(
129                    "catalog entries require a platform target".to_string(),
130                )
131            })?;
132            let key = format!(
133                "{}\n{}\n{}\n{}\n{}",
134                entry.application_id(),
135                target.os(),
136                target.architecture(),
137                target.format(),
138                entry.channel(),
139            );
140            let version_key = format!("{key}\n{}", entry.application_version());
141            if !keys.insert(version_key) {
142                return Err(UpdateError::InvalidArtifact(
143                    "catalog contains ambiguous duplicate release versions".to_string(),
144                ));
145            }
146            verifier.verify(entry)?;
147        }
148        Ok(Self { entries })
149    }
150
151    /// Selects the greatest compatible version above the installed version.
152    pub fn select(&self, identity: &UpdateIdentity) -> UpdateResult<Option<ArtifactDescriptor>> {
153        self.select_internal(identity, None)
154    }
155
156    /// Selects the greatest release above the installed version compatible
157    /// with the target, Runtime version and protocol.
158    pub fn select_latest_compatible(
159        &self,
160        identity: &LatestCompatibleUpdateIdentity,
161    ) -> UpdateResult<Option<ArtifactDescriptor>> {
162        let current = Version::parse(&identity.current_version).map_err(|error| {
163            UpdateError::Incompatible(format!("invalid installed version: {error}"))
164        })?;
165        validate_latest_identity(identity)?;
166        let mut selected: Option<(Version, ArtifactDescriptor)> = None;
167        for entry in &self.entries {
168            let Some(target) = entry.target() else {
169                continue;
170            };
171            if entry.application_id() != &identity.application_id
172                || target.os() != identity.os
173                || target.architecture() != identity.architecture
174                || target.format() != identity.format
175                || entry.channel() != identity.channel
176            {
177                continue;
178            }
179            let version = Version::parse(entry.application_version()).map_err(|error| {
180                UpdateError::InvalidArtifact(format!("invalid catalog version: {error}"))
181            })?;
182            if version <= current || selected.as_ref().is_some_and(|(best, _)| version <= *best) {
183                continue;
184            }
185            match entry.ensure_compatible(&identity.runtime_version, &identity.protocol_version) {
186                Ok(()) => selected = Some((version, entry.clone())),
187                Err(UpdateError::Incompatible(_)) => continue,
188                Err(error) => return Err(error),
189            }
190        }
191        Ok(selected.map(|(_, entry)| entry))
192    }
193
194    /// Selects the greatest compatible version that is not actively quarantined.
195    pub fn select_with_quarantine(
196        &self,
197        identity: &UpdateIdentity,
198        quarantine: &QuarantineStore,
199    ) -> UpdateResult<Option<ArtifactDescriptor>> {
200        Ok(self
201            .select_with_quarantine_at(identity, quarantine, current_time_ms())?
202            .selected)
203    }
204
205    /// Selects a release while returning every active quarantine exclusion.
206    pub fn select_with_quarantine_report(
207        &self,
208        identity: &UpdateIdentity,
209        quarantine: &QuarantineStore,
210        at_ms: u64,
211    ) -> UpdateResult<QuarantineSelection> {
212        let current = Version::parse(&identity.current_version).map_err(|error| {
213            UpdateError::Incompatible(format!("invalid installed version: {error}"))
214        })?;
215        validate_identity(identity)?;
216        let records = quarantine
217            .list()?
218            .into_iter()
219            .map(|record| (record.key.clone(), record))
220            .collect::<BTreeMap<_, _>>();
221        let mut selected: Option<(Version, ArtifactDescriptor)> = None;
222        let mut exclusions = Vec::new();
223        for entry in &self.entries {
224            let Some(target) = entry.target() else {
225                continue;
226            };
227            if entry.application_id() != &identity.application_id
228                || target.os() != identity.os
229                || target.architecture() != identity.architecture
230                || target.format() != identity.format
231                || entry.channel() != identity.channel
232            {
233                continue;
234            }
235            let version = Version::parse(entry.application_version()).map_err(|error| {
236                UpdateError::InvalidArtifact(format!("invalid catalog version: {error}"))
237            })?;
238            if version <= current {
239                continue;
240            }
241            let key = QuarantineKey::from_descriptor(entry);
242            if let Some(record) = records
243                .get(&key)
244                .filter(|record| record.is_active_at(at_ms))
245            {
246                exclusions.push(QuarantineExclusion {
247                    key,
248                    reason: record.reason.clone(),
249                    expires_at_ms: record.expires_at_ms,
250                });
251                continue;
252            }
253            if selected.as_ref().is_some_and(|(best, _)| version <= *best) {
254                continue;
255            }
256            selected = Some((version, entry.clone()));
257        }
258        exclusions.sort_by(|left, right| left.key.cmp(&right.key));
259        Ok(QuarantineSelection {
260            selected: selected.map(|(_, entry)| entry),
261            exclusions,
262        })
263    }
264
265    /// Selects a release at a deterministic timestamp while applying quarantine expiry.
266    pub fn select_with_quarantine_at(
267        &self,
268        identity: &UpdateIdentity,
269        quarantine: &QuarantineStore,
270        at_ms: u64,
271    ) -> UpdateResult<QuarantineSelection> {
272        self.select_with_quarantine_report(identity, quarantine, at_ms)
273    }
274
275    fn select_internal(
276        &self,
277        identity: &UpdateIdentity,
278        quarantine: Option<&QuarantineStore>,
279    ) -> UpdateResult<Option<ArtifactDescriptor>> {
280        let current = Version::parse(&identity.current_version).map_err(|error| {
281            UpdateError::Incompatible(format!("invalid installed version: {error}"))
282        })?;
283        validate_identity(identity)?;
284        let mut selected: Option<(Version, ArtifactDescriptor)> = None;
285        for entry in &self.entries {
286            let Some(target) = entry.target() else {
287                continue;
288            };
289            if entry.application_id() != &identity.application_id
290                || target.os() != identity.os
291                || target.architecture() != identity.architecture
292                || target.format() != identity.format
293                || entry.channel() != identity.channel
294            {
295                continue;
296            }
297            if let Some(store) = quarantine {
298                if store.is_quarantined(&QuarantineKey::from_descriptor(entry))? {
299                    continue;
300                }
301            }
302            let version = Version::parse(entry.application_version()).map_err(|error| {
303                UpdateError::InvalidArtifact(format!("invalid catalog version: {error}"))
304            })?;
305            if version <= current || selected.as_ref().is_some_and(|(best, _)| version <= *best) {
306                continue;
307            }
308            selected = Some((version, entry.clone()));
309        }
310        Ok(selected.map(|(_, entry)| entry))
311    }
312
313    /// Returns the number of validated catalog entries.
314    pub fn len(&self) -> usize {
315        self.entries.len()
316    }
317
318    /// Returns whether the catalog has no entries.
319    pub fn is_empty(&self) -> bool {
320        self.entries.is_empty()
321    }
322}
323
324fn current_time_ms() -> u64 {
325    SystemTime::now()
326        .duration_since(UNIX_EPOCH)
327        .unwrap_or_default()
328        .as_millis()
329        .try_into()
330        .unwrap_or(u64::MAX)
331}
332
333fn validate_identity(identity: &UpdateIdentity) -> UpdateResult<()> {
334    for (name, value) in [
335        ("os", identity.os.as_str()),
336        ("architecture", identity.architecture.as_str()),
337        ("format", identity.format.as_str()),
338        ("channel", identity.channel.as_str()),
339    ] {
340        if value.trim().is_empty() || value.len() > 64 || value.chars().any(char::is_control) {
341            return Err(UpdateError::Incompatible(format!(
342                "update identity {name} is invalid"
343            )));
344        }
345    }
346    Ok(())
347}
348
349fn validate_latest_identity(identity: &LatestCompatibleUpdateIdentity) -> UpdateResult<()> {
350    validate_identity(&UpdateIdentity {
351        application_id: identity.application_id.clone(),
352        os: identity.os.clone(),
353        architecture: identity.architecture.clone(),
354        format: identity.format.clone(),
355        channel: identity.channel.clone(),
356        current_version: identity.current_version.clone(),
357    })?;
358    for (name, value, max) in [
359        ("runtime_version", identity.runtime_version.as_str(), 64),
360        ("protocol_version", identity.protocol_version.as_str(), 64),
361    ] {
362        if value.trim().is_empty() || value.len() > max || value.chars().any(char::is_control) {
363            return Err(UpdateError::Incompatible(format!(
364                "update identity {name} is invalid"
365            )));
366        }
367    }
368    Version::parse(&identity.runtime_version)
369        .map_err(|error| UpdateError::Incompatible(format!("invalid runtime version: {error}")))?;
370    Ok(())
371}