Skip to main content

appcore_update/
cache.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: cache.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//! Resumable, hash-addressed artifact cache.
12
13use crate::store_io::{atomic_write_json, remove_if_exists, sync_parent_directory};
14use crate::stream::{receive_artifact_from, ArtifactTransferPolicy};
15use crate::{
16    ArtifactAuthenticityVerifier, ArtifactDescriptor, ArtifactSource, ArtifactWriter, UpdateError,
17    UpdateResult,
18};
19use fs2::FileExt;
20use serde::{Deserialize, Serialize};
21use sha2::{Digest, Sha256};
22use std::fs::{self, File, OpenOptions};
23use std::io::{Read, Seek, SeekFrom, Write};
24use std::path::{Path, PathBuf};
25
26const CACHE_MAX_DESCRIPTOR_BYTES: usize = 1024 * 1024;
27
28/// Configuration for one bounded update cache.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct CacheOptions {
31    /// Maximum aggregate bytes occupied by cache objects, descriptors and parts.
32    pub max_bytes: u64,
33    /// Applies the current owner-only directory profile where supported.
34    pub secure_permissions: bool,
35}
36
37impl CacheOptions {
38    /// Creates validated cache options.
39    pub fn new(max_bytes: u64, secure_permissions: bool) -> UpdateResult<Self> {
40        if max_bytes == 0 {
41            return Err(UpdateError::Store(
42                "cache max_bytes must be greater than zero".to_string(),
43            ));
44        }
45        Ok(Self {
46            max_bytes,
47            secure_permissions,
48        })
49    }
50}
51
52/// A fully verified artifact published by [`UpdateCache`].
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct CachedArtifact {
55    /// Verified descriptor retained beside the artifact.
56    pub descriptor: ArtifactDescriptor,
57    /// Immutable hash-addressed artifact path.
58    pub artifact_path: PathBuf,
59    /// Descriptor metadata path.
60    pub descriptor_path: PathBuf,
61}
62
63/// Filesystem cache for resumable, opaque artifact downloads.
64#[derive(Debug, Clone)]
65pub struct UpdateCache {
66    root: PathBuf,
67    options: CacheOptions,
68}
69
70#[derive(Debug, Serialize)]
71struct CachedDescriptor<'a> {
72    descriptor: &'a ArtifactDescriptor,
73}
74
75#[derive(Debug, Deserialize)]
76struct StoredDescriptor {
77    descriptor: ArtifactDescriptor,
78}
79
80impl UpdateCache {
81    /// Opens or creates a cache root and its bounded subdirectories.
82    pub fn open(root: impl Into<PathBuf>, options: CacheOptions) -> UpdateResult<Self> {
83        let cache = Self {
84            root: root.into(),
85            options,
86        };
87        cache.initialize()?;
88        Ok(cache)
89    }
90
91    /// Stages one artifact, resuming a valid partial file when possible.
92    pub fn stage(
93        &self,
94        verifier: &dyn ArtifactAuthenticityVerifier,
95        descriptor: &ArtifactDescriptor,
96        source: &dyn ArtifactSource,
97        transfer: &ArtifactTransferPolicy,
98    ) -> UpdateResult<CachedArtifact> {
99        descriptor.validate()?;
100        verifier.verify(descriptor)?;
101        let _lock = self.lock()?;
102        self.initialize()?;
103        let artifact_path = self.artifact_path(descriptor);
104        let descriptor_path = self.descriptor_path(descriptor);
105        if self.is_complete(descriptor, &artifact_path, &descriptor_path)? {
106            return Ok(CachedArtifact {
107                descriptor: descriptor.clone(),
108                artifact_path,
109                descriptor_path,
110            });
111        }
112        remove_if_exists(&artifact_path)?;
113        remove_if_exists(&descriptor_path)?;
114        let part_path = self.part_path(descriptor);
115        let partial_len = fs::metadata(&part_path)
116            .map(|metadata| metadata.len())
117            .unwrap_or(0);
118        let result = self.receive_partial(descriptor, source, transfer);
119        if matches!(result, Err(UpdateError::ChecksumMismatch)) && partial_len > 0 {
120            remove_if_exists(&part_path)?;
121            self.receive_partial(descriptor, source, transfer)?;
122        } else {
123            result?;
124        }
125        if fs::metadata(&part_path).map_err(store_error)?.len() != descriptor.size_bytes() {
126            return Err(UpdateError::ChecksumMismatch);
127        }
128        fs::rename(&part_path, &artifact_path).map_err(store_error)?;
129        sync_parent_directory(&self.objects_dir())?;
130        atomic_write_json(&descriptor_path, &CachedDescriptor { descriptor })?;
131        Ok(CachedArtifact {
132            descriptor: descriptor.clone(),
133            artifact_path,
134            descriptor_path,
135        })
136    }
137
138    /// Returns the hash-addressed artifact path for a descriptor.
139    pub fn artifact_path(&self, descriptor: &ArtifactDescriptor) -> PathBuf {
140        self.objects_dir()
141            .join(format!("{}.bin", descriptor.sha256()))
142    }
143
144    /// Returns the descriptor metadata path for a descriptor.
145    pub fn descriptor_path(&self, descriptor: &ArtifactDescriptor) -> PathBuf {
146        self.objects_dir()
147            .join(format!("{}.json", descriptor.sha256()))
148    }
149
150    /// Returns the resumable partial path for a descriptor.
151    pub fn partial_path(&self, descriptor: &ArtifactDescriptor) -> PathBuf {
152        self.part_path(descriptor)
153    }
154
155    fn initialize(&self) -> UpdateResult<()> {
156        let root_created = ensure_directory(&self.root)?;
157        let objects = self.objects_dir();
158        let parts = self.parts_dir();
159        let objects_created = ensure_directory(&objects)?;
160        let parts_created = ensure_directory(&parts)?;
161        if self.options.secure_permissions {
162            if root_created {
163                set_private_directory_permissions(&self.root)?;
164            }
165            if objects_created {
166                set_private_directory_permissions(&objects)?;
167            }
168            if parts_created {
169                set_private_directory_permissions(&parts)?;
170            }
171            validate_private_directory(&self.root)?;
172            validate_private_directory(&objects)?;
173            validate_private_directory(&parts)?;
174            validate_private_ancestors(&self.root)?;
175        }
176        Ok(())
177    }
178
179    fn lock(&self) -> UpdateResult<File> {
180        let path = self.root.join("cache.lock");
181        if let Ok(metadata) = fs::symlink_metadata(&path) {
182            if metadata.file_type().is_symlink() || !metadata.is_file() {
183                return Err(UpdateError::Store(
184                    "cache lock path is not a regular file".to_string(),
185                ));
186            }
187        }
188        let file = OpenOptions::new()
189            .create(true)
190            .read(true)
191            .write(true)
192            .truncate(false)
193            .open(path)
194            .map_err(store_error)?;
195        file.try_lock_exclusive()
196            .map_err(|error| UpdateError::Store(format!("cache lock is unavailable: {error}")))?;
197        Ok(file)
198    }
199
200    fn is_complete(
201        &self,
202        descriptor: &ArtifactDescriptor,
203        artifact_path: &Path,
204        descriptor_path: &Path,
205    ) -> UpdateResult<bool> {
206        let stored: StoredDescriptor =
207            match crate::store_io::read_json_bounded(descriptor_path, CACHE_MAX_DESCRIPTOR_BYTES) {
208                Ok(Some(value)) => value,
209                Ok(None) => return Ok(false),
210                Err(crate::store_io::JsonReadError::Io(error)) => {
211                    let _ = remove_if_exists(descriptor_path);
212                    return Err(UpdateError::Store(error.to_string()));
213                }
214                Err(crate::store_io::JsonReadError::Decode(error)) => {
215                    let _ = remove_if_exists(descriptor_path);
216                    return Err(UpdateError::Store(error.to_string()));
217                }
218            };
219        let artifact_metadata = match fs::symlink_metadata(artifact_path) {
220            Ok(metadata) => metadata,
221            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
222            Err(error) => return Err(store_error(error)),
223        };
224        if stored.descriptor != *descriptor
225            || artifact_metadata.file_type().is_symlink()
226            || !artifact_metadata.is_file()
227        {
228            return Ok(false);
229        }
230        match verify_cached_artifact(artifact_path, descriptor) {
231            Ok(()) => {}
232            Err(UpdateError::ChecksumMismatch) => {
233                remove_if_exists(artifact_path)?;
234                remove_if_exists(descriptor_path)?;
235                return Ok(false);
236            }
237            Err(error) => return Err(error),
238        }
239        Ok(true)
240    }
241
242    fn receive_partial(
243        &self,
244        descriptor: &ArtifactDescriptor,
245        source: &dyn ArtifactSource,
246        transfer: &ArtifactTransferPolicy,
247    ) -> UpdateResult<()> {
248        let part_path = self.part_path(descriptor);
249        let mut partial = self.open_partial(&part_path, descriptor)?;
250        let partial_len = partial.metadata().map_err(store_error)?.len();
251        let mut hasher = Sha256::new();
252        hash_existing(&mut partial, partial_len, &mut hasher)?;
253        let metadata_bytes = serde_json::to_vec(&CachedDescriptor { descriptor })
254            .map_err(|error| UpdateError::Store(error.to_string()))?
255            .len() as u64;
256        let required = descriptor
257            .size_bytes()
258            .checked_sub(partial_len)
259            .and_then(|bytes| bytes.checked_add(metadata_bytes))
260            .ok_or(UpdateError::ChecksumMismatch)?;
261        if self.usage()?.saturating_add(required) > self.options.max_bytes {
262            return Err(UpdateError::Store(
263                "update cache quota exceeded".to_string(),
264            ));
265        }
266        let mut writer = PartialWriter { file: partial };
267        let result = receive_artifact_from(
268            transfer,
269            descriptor,
270            source,
271            &mut writer,
272            partial_len,
273            hasher,
274        );
275        let mut partial = writer.file;
276        partial.flush().map_err(store_error)?;
277        partial.sync_all().map_err(store_error)?;
278        result
279    }
280
281    fn open_partial(&self, path: &Path, descriptor: &ArtifactDescriptor) -> UpdateResult<File> {
282        if let Ok(metadata) = fs::symlink_metadata(path) {
283            if metadata.file_type().is_symlink() || !metadata.is_file() {
284                return Err(UpdateError::Store(
285                    "cache partial path is not a regular file".to_string(),
286                ));
287            }
288            if metadata.len() > descriptor.size_bytes() {
289                remove_if_exists(path)?;
290            }
291        }
292        OpenOptions::new()
293            .create(true)
294            .read(true)
295            .write(true)
296            .truncate(false)
297            .open(path)
298            .map_err(store_error)
299    }
300
301    fn usage(&self) -> UpdateResult<u64> {
302        let mut total = 0_u64;
303        for directory in [self.objects_dir(), self.parts_dir()] {
304            for entry in fs::read_dir(directory).map_err(store_error)? {
305                let entry = entry.map_err(store_error)?;
306                let metadata = entry.metadata().map_err(store_error)?;
307                if !metadata.is_file() {
308                    return Err(UpdateError::Store(
309                        "cache contains a non-regular entry".to_string(),
310                    ));
311                }
312                total = total
313                    .checked_add(metadata.len())
314                    .ok_or_else(|| UpdateError::Store("cache usage overflow".to_string()))?;
315            }
316        }
317        Ok(total)
318    }
319
320    fn objects_dir(&self) -> PathBuf {
321        self.root.join("objects")
322    }
323
324    fn parts_dir(&self) -> PathBuf {
325        self.root.join("parts")
326    }
327
328    fn part_path(&self, descriptor: &ArtifactDescriptor) -> PathBuf {
329        self.parts_dir()
330            .join(format!("{}.part", descriptor.sha256()))
331    }
332}
333
334struct PartialWriter {
335    file: File,
336}
337
338impl ArtifactWriter for PartialWriter {
339    fn write_chunk(&mut self, offset: u64, bytes: &[u8]) -> UpdateResult<()> {
340        self.file
341            .seek(SeekFrom::Start(offset))
342            .and_then(|_| self.file.write_all(bytes))
343            .map_err(store_error)
344    }
345}
346
347fn hash_existing(file: &mut File, length: u64, hasher: &mut Sha256) -> UpdateResult<()> {
348    file.seek(SeekFrom::Start(0)).map_err(store_error)?;
349    let mut remaining = length;
350    let mut buffer = vec![0_u8; 64 * 1024];
351    while remaining > 0 {
352        let requested = usize::try_from(remaining)
353            .unwrap_or(buffer.len())
354            .min(buffer.len());
355        let read = file.read(&mut buffer[..requested]).map_err(store_error)?;
356        if read == 0 {
357            return Err(UpdateError::ChecksumMismatch);
358        }
359        hasher.update(&buffer[..read]);
360        remaining -= read as u64;
361    }
362    Ok(())
363}
364
365fn verify_cached_artifact(path: &Path, descriptor: &ArtifactDescriptor) -> UpdateResult<()> {
366    let mut file = File::open(path).map_err(store_error)?;
367    if file.metadata().map_err(store_error)?.len() != descriptor.size_bytes() {
368        return Err(UpdateError::ChecksumMismatch);
369    }
370    let mut hasher = Sha256::new();
371    let mut buffer = vec![0_u8; 64 * 1024];
372    loop {
373        let read = file.read(&mut buffer).map_err(store_error)?;
374        if read == 0 {
375            break;
376        }
377        hasher.update(&buffer[..read]);
378    }
379    if crate::integrity::encode_hex(&hasher.finalize()) != descriptor.sha256() {
380        return Err(UpdateError::ChecksumMismatch);
381    }
382    Ok(())
383}
384
385fn reject_directory(path: &Path) -> UpdateResult<()> {
386    let metadata = fs::symlink_metadata(path).map_err(store_error)?;
387    if metadata.file_type().is_symlink() || !metadata.is_dir() {
388        return Err(UpdateError::Store(
389            "cache path is not a regular directory".to_string(),
390        ));
391    }
392    Ok(())
393}
394
395fn ensure_directory(path: &Path) -> UpdateResult<bool> {
396    match fs::symlink_metadata(path) {
397        Ok(metadata) => {
398            if metadata.file_type().is_symlink() || !metadata.is_dir() {
399                return Err(UpdateError::Store(
400                    "cache path is not a regular directory".to_string(),
401                ));
402            }
403            Ok(false)
404        }
405        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
406            fs::create_dir_all(path).map_err(store_error)?;
407            reject_directory(path)?;
408            Ok(true)
409        }
410        Err(error) => Err(store_error(error)),
411    }
412}
413
414fn store_error(error: std::io::Error) -> UpdateError {
415    UpdateError::Store(error.to_string())
416}
417
418fn set_private_directory_permissions(path: &Path) -> UpdateResult<()> {
419    #[cfg(unix)]
420    {
421        use std::os::unix::fs::PermissionsExt;
422        fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(store_error)?;
423    }
424    Ok(())
425}
426
427fn validate_private_directory(path: &Path) -> UpdateResult<()> {
428    #[cfg(unix)]
429    {
430        use std::os::unix::fs::{MetadataExt, PermissionsExt};
431        let metadata = fs::symlink_metadata(path).map_err(store_error)?;
432        if metadata.file_type().is_symlink()
433            || !metadata.is_dir()
434            || metadata.uid() != unsafe { libc::geteuid() }
435            || metadata.permissions().mode() & 0o077 != 0
436        {
437            return Err(UpdateError::Store(
438                "cache directory permissions are not owner-only".to_string(),
439            ));
440        }
441    }
442    Ok(())
443}
444
445fn validate_private_ancestors(path: &Path) -> UpdateResult<()> {
446    #[cfg(unix)]
447    {
448        use std::os::unix::fs::{MetadataExt, PermissionsExt};
449        let mut current = path.to_path_buf();
450        while let Some(parent) = current.parent() {
451            if parent == current {
452                break;
453            }
454            let metadata = fs::symlink_metadata(parent).map_err(store_error)?;
455            let mode = metadata.permissions().mode();
456            let safe_sticky_root = mode & 0o1000 != 0 && metadata.uid() == 0;
457            if metadata.file_type().is_symlink()
458                || !metadata.is_dir()
459                || (mode & 0o022 != 0 && !safe_sticky_root)
460            {
461                return Err(UpdateError::Store(
462                    "cache ancestor is writable by an untrusted principal".to_string(),
463                ));
464            }
465            current = parent.to_path_buf();
466        }
467    }
468    Ok(())
469}