1use 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
22pub const QUARANTINE_FORMAT_VERSION: u16 = 1;
24pub const QUARANTINE_MAX_ENTRIES: usize = 1_024;
26const MAX_REASON_BYTES: usize = 512;
27const MAX_DOCUMENT_BYTES: usize = 1024 * 1024;
28
29#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
31pub struct QuarantineKey {
32 pub application_id: String,
34 pub channel: String,
36 pub application_version: String,
38 pub build_id: String,
40 pub sha256: String,
42}
43
44impl QuarantineKey {
45 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(rename_all = "snake_case", tag = "kind", content = "detail")]
87pub enum QuarantineReason {
88 HealthCheckFailed(String),
90 ActivationFailed(String),
92 ChecksumMismatch,
94 AuthenticityRejected(String),
96 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(rename_all = "snake_case")]
124pub enum QuarantineState {
125 Active,
127 Released,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
133pub struct QuarantineRecord {
134 pub key: QuarantineKey,
136 pub reason: QuarantineReason,
138 pub quarantined_at_ms: u64,
140 pub updated_at_ms: u64,
142 pub state: QuarantineState,
144 #[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#[derive(Debug, Clone)]
178pub struct QuarantineStore {
179 root: PathBuf,
180 max_entries: usize,
181}
182
183impl QuarantineStore {
184 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 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 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 pub fn is_quarantined(&self, key: &QuarantineKey) -> UpdateResult<bool> {
258 self.is_quarantined_at(key, current_time_ms())
259 }
260
261 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 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 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 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}