Skip to main content

appcore_update/
quarantine.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: quarantine.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 persistent quarantine for releases that failed health validation.
12
13use crate::store_io::{
14    atomic_write_json, read_json_bounded, JsonReadError, MAX_UPDATE_METADATA_BYTES,
15};
16use crate::{ArtifactDescriptor, UpdateError, UpdateResult};
17use fs2::FileExt;
18use serde::{Deserialize, Serialize};
19use std::fs::{self, File, OpenOptions};
20use std::path::{Path, PathBuf};
21
22/// Version of the persistent quarantine document.
23pub const QUARANTINE_FORMAT_VERSION: u16 = 1;
24/// Maximum number of release keys accepted by one store.
25pub const QUARANTINE_MAX_ENTRIES: usize = 1_024;
26const MAX_REASON_BYTES: usize = 512;
27const MAX_DOCUMENT_BYTES: usize = 1024 * 1024;
28
29/// Complete release identity used as the quarantine key.
30#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
31pub struct QuarantineKey {
32    /// Application identity.
33    pub application_id: String,
34    /// Release channel.
35    pub channel: String,
36    /// Semantic application version.
37    pub application_version: String,
38    /// Immutable build identity.
39    pub build_id: String,
40    /// Artifact digest.
41    pub sha256: String,
42}
43
44impl QuarantineKey {
45    /// Creates a key from a validated artifact descriptor.
46    pub fn from_descriptor(descriptor: &ArtifactDescriptor) -> Self {
47        Self {
48            application_id: descriptor.application_id().as_str().to_string(),
49            channel: descriptor.channel().to_string(),
50            application_version: descriptor.application_version().to_string(),
51            build_id: descriptor.build_id().as_str().to_string(),
52            sha256: descriptor.sha256().to_string(),
53        }
54    }
55
56    fn validate(&self) -> UpdateResult<()> {
57        for (name, value, max) in [
58            ("application_id", self.application_id.as_str(), 128),
59            ("channel", self.channel.as_str(), 64),
60            ("application_version", self.application_version.as_str(), 64),
61            ("build_id", self.build_id.as_str(), 128),
62            ("sha256", self.sha256.as_str(), 64),
63        ] {
64            if value.trim().is_empty() || value.len() > max || value.chars().any(char::is_control) {
65                return Err(UpdateError::Recovery(format!(
66                    "quarantine key field {name} is invalid"
67                )));
68            }
69        }
70        if self.sha256.len() != 64
71            || !self
72                .sha256
73                .bytes()
74                .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
75        {
76            return Err(UpdateError::Recovery(
77                "quarantine key sha256 is invalid".to_string(),
78            ));
79        }
80        Ok(())
81    }
82}
83
84/// Typed reason why a release was quarantined.
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(rename_all = "snake_case", tag = "kind", content = "detail")]
87pub enum QuarantineReason {
88    /// The activated release failed the deployment health check.
89    HealthCheckFailed(String),
90    /// Activation failed before the release became healthy.
91    ActivationFailed(String),
92    /// The artifact bytes did not match the declared digest.
93    ChecksumMismatch,
94    /// Authenticity or trust validation failed.
95    AuthenticityRejected(String),
96    /// An operator explicitly required review before reuse.
97    ManualReview(String),
98}
99
100impl QuarantineReason {
101    fn validate(&self) -> UpdateResult<()> {
102        let detail = match self {
103            Self::HealthCheckFailed(detail)
104            | Self::ActivationFailed(detail)
105            | Self::AuthenticityRejected(detail)
106            | Self::ManualReview(detail) => detail,
107            Self::ChecksumMismatch => return Ok(()),
108        };
109        if detail.trim().is_empty()
110            || detail.len() > MAX_REASON_BYTES
111            || detail.chars().any(char::is_control)
112        {
113            return Err(UpdateError::Recovery(
114                "quarantine reason is empty, too long or contains control characters".to_string(),
115            ));
116        }
117        Ok(())
118    }
119}
120
121/// Current state of a quarantine entry.
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(rename_all = "snake_case")]
124pub enum QuarantineState {
125    /// Automatic selection must skip this release.
126    Active,
127    /// An operator explicitly released this release for selection.
128    Released,
129}
130
131/// Persisted bounded quarantine entry.
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133pub struct QuarantineRecord {
134    /// Complete release identity.
135    pub key: QuarantineKey,
136    /// Typed quarantine reason.
137    pub reason: QuarantineReason,
138    /// First time this key entered quarantine.
139    pub quarantined_at_ms: u64,
140    /// Last state or reason transition.
141    pub updated_at_ms: u64,
142    /// Current quarantine state.
143    pub state: QuarantineState,
144    /// Optional exclusive expiry timestamp in Unix milliseconds.
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub expires_at_ms: Option<u64>,
147}
148
149impl QuarantineRecord {
150    fn validate(&self) -> UpdateResult<()> {
151        self.key.validate()?;
152        self.reason.validate()?;
153        if self.updated_at_ms < self.quarantined_at_ms {
154            return Err(UpdateError::Recovery(
155                "quarantine transition timestamp precedes creation".to_string(),
156            ));
157        }
158        if self
159            .expires_at_ms
160            .is_some_and(|expires| expires <= self.quarantined_at_ms)
161        {
162            return Err(UpdateError::Recovery(
163                "quarantine expiry must be after its creation timestamp".to_string(),
164            ));
165        }
166        Ok(())
167    }
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize)]
171struct QuarantineDocument {
172    format_version: u16,
173    entries: Vec<QuarantineRecord>,
174}
175
176/// Bounded, restart-persistent quarantine store.
177#[derive(Debug, Clone)]
178pub struct QuarantineStore {
179    root: PathBuf,
180    max_entries: usize,
181}
182
183impl QuarantineStore {
184    /// Opens or creates a quarantine store with a bounded entry count.
185    pub fn open(root: impl Into<PathBuf>, max_entries: usize) -> UpdateResult<Self> {
186        if max_entries == 0 || max_entries > QUARANTINE_MAX_ENTRIES {
187            return Err(UpdateError::Recovery(
188                "quarantine max_entries is outside the supported bound".to_string(),
189            ));
190        }
191        let store = Self {
192            root: root.into(),
193            max_entries,
194        };
195        fs::create_dir_all(&store.root).map_err(store_error)?;
196        reject_directory(&store.root)?;
197        Ok(store)
198    }
199
200    /// Records or reactivates a release quarantine entry.
201    pub fn quarantine(
202        &self,
203        descriptor: &ArtifactDescriptor,
204        reason: QuarantineReason,
205        at_ms: u64,
206    ) -> UpdateResult<QuarantineRecord> {
207        self.quarantine_until(descriptor, reason, at_ms, None)
208    }
209
210    /// Records or reactivates a quarantine entry with an optional expiry.
211    pub fn quarantine_until(
212        &self,
213        descriptor: &ArtifactDescriptor,
214        reason: QuarantineReason,
215        at_ms: u64,
216        expires_at_ms: Option<u64>,
217    ) -> UpdateResult<QuarantineRecord> {
218        descriptor.validate()?;
219        reason.validate()?;
220        if expires_at_ms.is_some_and(|expires| expires <= at_ms) {
221            return Err(UpdateError::Recovery(
222                "quarantine expiry must be after its creation timestamp".to_string(),
223            ));
224        }
225        let _lock = self.lock()?;
226        let mut document = self.read_document()?;
227        let key = QuarantineKey::from_descriptor(descriptor);
228        if let Some(entry) = document.entries.iter_mut().find(|entry| entry.key == key) {
229            entry.reason = reason;
230            entry.updated_at_ms = at_ms;
231            entry.state = QuarantineState::Active;
232            entry.expires_at_ms = expires_at_ms;
233            let result = entry.clone();
234            self.write_document(&document)?;
235            return Ok(result);
236        }
237        if document.entries.len() >= self.max_entries {
238            return Err(UpdateError::Recovery(
239                "quarantine entry bound exceeded; explicit release is required".to_string(),
240            ));
241        }
242        let entry = QuarantineRecord {
243            key,
244            reason,
245            quarantined_at_ms: at_ms,
246            updated_at_ms: at_ms,
247            state: QuarantineState::Active,
248            expires_at_ms,
249        };
250        entry.validate()?;
251        document.entries.push(entry.clone());
252        self.write_document(&document)?;
253        Ok(entry)
254    }
255
256    /// Returns whether automatic selection must skip a release key.
257    pub fn is_quarantined(&self, key: &QuarantineKey) -> UpdateResult<bool> {
258        self.is_quarantined_at(key, current_time_ms())
259    }
260
261    /// Returns whether a release is active at a specific Unix timestamp.
262    pub fn is_quarantined_at(&self, key: &QuarantineKey, at_ms: u64) -> UpdateResult<bool> {
263        key.validate()?;
264        let _lock = self.lock()?;
265        Ok(self
266            .read_document()?
267            .entries
268            .iter()
269            .any(|entry| entry.key == *key && entry.is_active_at(at_ms)))
270    }
271
272    /// Returns all entries in stable key order without changing state.
273    pub fn list(&self) -> UpdateResult<Vec<QuarantineRecord>> {
274        let _lock = self.lock()?;
275        let mut entries = self.read_document()?.entries;
276        entries.sort_by(|left, right| left.key.cmp(&right.key));
277        Ok(entries)
278    }
279
280    /// Explicitly releases a key while retaining its history across restarts.
281    pub fn release(&self, key: &QuarantineKey, at_ms: u64) -> UpdateResult<bool> {
282        key.validate()?;
283        let _lock = self.lock()?;
284        let mut document = self.read_document()?;
285        let Some(entry) = document.entries.iter_mut().find(|entry| entry.key == *key) else {
286            return Ok(false);
287        };
288        if entry.state == QuarantineState::Released {
289            return Ok(false);
290        }
291        entry.state = QuarantineState::Released;
292        entry.updated_at_ms = at_ms;
293        self.write_document(&document)?;
294        Ok(true)
295    }
296
297    fn read_document(&self) -> UpdateResult<QuarantineDocument> {
298        let document = match read_json_bounded::<QuarantineDocument>(
299            &self.document_path(),
300            MAX_DOCUMENT_BYTES.min(MAX_UPDATE_METADATA_BYTES),
301        ) {
302            Ok(Some(document)) => document,
303            Ok(None) => QuarantineDocument {
304                format_version: QUARANTINE_FORMAT_VERSION,
305                entries: Vec::new(),
306            },
307            Err(JsonReadError::Io(error)) => return Err(store_error(error)),
308            Err(JsonReadError::Decode(_)) => {
309                return Err(UpdateError::Recovery(
310                    "NO MORE SUPPORTED PLEASE UPDATE".to_string(),
311                ))
312            }
313        };
314        if document.format_version != QUARANTINE_FORMAT_VERSION
315            || document.entries.len() > self.max_entries
316        {
317            return Err(UpdateError::Recovery(
318                "NO MORE SUPPORTED PLEASE UPDATE".to_string(),
319            ));
320        }
321        for entry in &document.entries {
322            entry.validate()?;
323        }
324        Ok(document)
325    }
326
327    fn write_document(&self, document: &QuarantineDocument) -> UpdateResult<()> {
328        atomic_write_json(&self.document_path(), document)
329    }
330
331    fn lock(&self) -> UpdateResult<File> {
332        let path = self.root.join("quarantine.lock");
333        if let Ok(metadata) = fs::symlink_metadata(&path) {
334            if metadata.file_type().is_symlink() || !metadata.is_file() {
335                return Err(UpdateError::Recovery(
336                    "quarantine lock path is not a regular file".to_string(),
337                ));
338            }
339        }
340        let file = OpenOptions::new()
341            .create(true)
342            .read(true)
343            .write(true)
344            .truncate(false)
345            .open(path)
346            .map_err(store_error)?;
347        file.try_lock_exclusive().map_err(|error| {
348            UpdateError::Recovery(format!("quarantine lock is unavailable: {error}"))
349        })?;
350        Ok(file)
351    }
352
353    fn document_path(&self) -> PathBuf {
354        self.root.join("quarantine.json")
355    }
356}
357
358impl QuarantineRecord {
359    /// Returns whether automatic selection must skip this record at a time.
360    pub fn is_active_at(&self, at_ms: u64) -> bool {
361        self.state == QuarantineState::Active
362            && self.expires_at_ms.is_none_or(|expires| at_ms < expires)
363    }
364}
365
366fn reject_directory(path: &Path) -> UpdateResult<()> {
367    let metadata = fs::symlink_metadata(path).map_err(store_error)?;
368    if metadata.file_type().is_symlink() || !metadata.is_dir() {
369        return Err(UpdateError::Recovery(
370            "quarantine root is not a regular directory".to_string(),
371        ));
372    }
373    Ok(())
374}
375
376fn store_error(error: std::io::Error) -> UpdateError {
377    UpdateError::Recovery(error.to_string())
378}
379
380fn current_time_ms() -> u64 {
381    std::time::SystemTime::now()
382        .duration_since(std::time::UNIX_EPOCH)
383        .unwrap_or_default()
384        .as_millis()
385        .try_into()
386        .unwrap_or(u64::MAX)
387}