Skip to main content

starfield_datastore/store/
mod.rs

1//! The store: the local content-addressed layer and the resolution chain
2//! (spec §7, §12).
3//!
4//! `get` resolves `local disk → mirror → upstream`, populating the local
5//! layer as it goes. Every byte that lands in the cache has passed the
6//! artifact's `ContentCheck`; the blob is written to `tmp/`, hashed and
7//! validated, then renamed into `blobs/` under its SHA-256. The chain only
8//! ever reads a mirror: writing the mirror is an explicit act of the
9//! ephemeris server.
10
11mod layout;
12
13use crate::check::digest_file;
14use crate::fetch::{sanitize, Fetcher, Progress};
15use crate::mirror::MirrorRead;
16use crate::{
17    config, mirror, Artifact, ArtifactKey, CheckFailure, ContentCheck, CredentialProvider,
18    DatastoreError, Mirror, Result,
19};
20use layout::{now_unix, Layout};
21use serde::{Deserialize, Serialize};
22use sha2::{Digest, Sha256};
23use std::cell::Cell;
24use std::collections::{HashMap, HashSet};
25use std::io::Write;
26use std::path::{Path, PathBuf};
27use std::sync::Arc;
28use std::time::{Duration, Instant};
29use tempfile::NamedTempFile;
30
31/// How many leading bytes are shown to `ContentCheck::check_prefix` before
32/// the rest of a transfer is allowed to proceed.
33const PREFIX_BYTES: usize = 8 * 1024;
34
35const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
36
37/// Which layer of the chain served a request.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum Layer {
41    LocalDisk,
42    Mirror,
43    Upstream,
44}
45
46#[derive(Debug, Clone)]
47pub struct ResolveOutcome {
48    pub layer: Layer,
49    pub bytes: u64,
50    pub duration: Duration,
51    /// Which `Source` index succeeded, when `layer == Upstream`.
52    pub source_index: Option<usize>,
53}
54
55/// The index sidecar for one key (spec §12). Never holds a secret: the
56/// provider *identity* is recorded, not the credential.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct IndexEntry {
59    /// Lowercase hex SHA-256; also the blob's address.
60    pub digest: String,
61    pub bytes: u64,
62    /// Unix seconds.
63    pub fetched_at: u64,
64    /// Sanitised origin and path of what was fetched, the mirror location,
65    /// or `local:import`. Never a query string.
66    pub source: Option<String>,
67    pub etag: Option<String>,
68    pub provider_identity: Option<String>,
69    pub layer: Layer,
70}
71
72/// A key whose blob no longer agrees with its index entry.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct VerifyFailure {
75    pub key: ArtifactKey,
76    /// The digest the index records.
77    pub expected: String,
78    /// The digest the blob hashes to now; `None` when it is missing.
79    pub actual: Option<String>,
80    /// Why this is a failure, in words: a missing blob, a digest mismatch,
81    /// or a size that disagrees with the sidecar.
82    pub reason: String,
83}
84
85impl std::fmt::Display for VerifyFailure {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        write!(f, "{}: {}", self.key, self.reason)
88    }
89}
90
91/// Progress callback: `(bytes received so far, total if known)`.
92pub type ProgressFn = dyn Fn(u64, Option<u64>) + Send + Sync;
93
94pub struct Datastore {
95    layout: Layout,
96    mirror: Option<Box<dyn MirrorRead>>,
97    fetcher: Fetcher,
98    allow_upstream: bool,
99    offline: bool,
100    max_bytes: Option<u64>,
101}
102
103#[derive(Default)]
104pub struct DatastoreBuilder {
105    pub(crate) cache_root: Option<PathBuf>,
106    pub(crate) mirror: Option<Mirror>,
107    pub(crate) credentials: Option<Box<dyn CredentialProvider>>,
108    pub(crate) allow_upstream: Option<bool>,
109    pub(crate) offline: Option<bool>,
110    pub(crate) timeout: Option<Duration>,
111    pub(crate) progress: Option<bool>,
112    pub(crate) on_progress: Option<Arc<ProgressFn>>,
113    pub(crate) max_bytes: Option<u64>,
114}
115
116impl DatastoreBuilder {
117    /// A builder pre-filled from the environment and the config file
118    /// (spec §11). Calls made afterwards override what was read.
119    pub fn from_env() -> Result<Self> {
120        config::apply(Self::default())
121    }
122
123    pub fn cache_root(mut self, path: PathBuf) -> Self {
124        self.cache_root = Some(path);
125        self
126    }
127
128    pub fn mirror(mut self, mirror: Mirror) -> Self {
129        self.mirror = Some(mirror);
130        self
131    }
132
133    /// Drop any mirror picked up from the environment. The ephemeris server
134    /// uses this so it never resolves through itself.
135    pub fn without_mirror(mut self) -> Self {
136        self.mirror = None;
137        self
138    }
139
140    pub fn credentials(mut self, provider: Box<dyn CredentialProvider>) -> Self {
141        self.credentials = Some(provider);
142        self
143    }
144
145    /// Permit the upstream layer. Default false; see spec §2.4.
146    pub fn allow_upstream(mut self, allow: bool) -> Self {
147        self.allow_upstream = Some(allow);
148        self
149    }
150
151    /// Disable the mirror and upstream layers. What CI sets once the cache
152    /// is warm.
153    pub fn offline(mut self, offline: bool) -> Self {
154        self.offline = Some(offline);
155        self
156    }
157
158    /// Connect timeout. There is deliberately no read or total timeout: a
159    /// 12 GB mosaic takes as long as it takes.
160    pub fn timeout(mut self, timeout: Duration) -> Self {
161        self.timeout = Some(timeout);
162        self
163    }
164
165    /// Draw progress bars on stderr (feature `progress`). Default on; bars
166    /// are hidden when stderr is not a terminal.
167    pub fn progress(mut self, enabled: bool) -> Self {
168        self.progress = Some(enabled);
169        self
170    }
171
172    /// Receive `(received, total)` per chunk instead of drawing bars.
173    pub fn on_progress(mut self, callback: Box<ProgressFn>) -> Self {
174        self.on_progress = Some(Arc::from(callback));
175        self
176    }
177
178    /// Advisory cache budget; applied only by an explicit `gc`.
179    pub fn max_bytes(mut self, max: u64) -> Self {
180        self.max_bytes = Some(max);
181        self
182    }
183
184    pub fn build(self) -> Result<Datastore> {
185        let root = match self.cache_root {
186            Some(root) => root,
187            None => config::default_cache_root()?,
188        };
189        let layout = Layout::new(root);
190        layout.ensure()?;
191        let timeout = self.timeout.unwrap_or(DEFAULT_TIMEOUT);
192        let progress = match (self.on_progress, self.progress) {
193            (Some(callback), _) => Progress::Callback(callback),
194            (None, Some(false)) => Progress::Off,
195            (None, _) => Progress::Bars,
196        };
197        let provider: Option<Arc<dyn CredentialProvider>> = self.credentials.map(Arc::from);
198        let fetcher = Fetcher::new(timeout, provider, progress.clone(), true)?;
199        let mirror = match self.mirror {
200            Some(m) => Some(mirror::open(&m, timeout, progress)?),
201            None => None,
202        };
203        Ok(Datastore {
204            layout,
205            mirror,
206            fetcher,
207            allow_upstream: self.allow_upstream.unwrap_or(false),
208            offline: self.offline.unwrap_or(false),
209            max_bytes: self.max_bytes,
210        })
211    }
212}
213
214impl Datastore {
215    pub fn builder() -> DatastoreBuilder {
216        DatastoreBuilder::default()
217    }
218
219    /// From env + `~/.config/starfield/datastore.toml` + defaults.
220    pub fn from_env() -> Result<Self> {
221        DatastoreBuilder::from_env()?.build()
222    }
223
224    pub fn cache_root(&self) -> &Path {
225        self.layout.root()
226    }
227
228    /// The configured cache budget, if any. Enforced only by `gc`.
229    pub fn max_bytes(&self) -> Option<u64> {
230        self.max_bytes
231    }
232
233    /// Resolve to a local path, fetching through the chain if needed.
234    pub fn get(&self, artifact: &Artifact) -> Result<PathBuf> {
235        self.get_with_outcome(artifact).map(|(path, _)| path)
236    }
237
238    /// As `get`, reporting which layer served it.
239    pub fn get_with_outcome(&self, artifact: &Artifact) -> Result<(PathBuf, ResolveOutcome)> {
240        self.get_with_budget(artifact, None)
241    }
242
243    #[cfg(feature = "server")]
244    pub(crate) fn get_limited(&self, artifact: &Artifact, bytes: u64) -> Result<PathBuf> {
245        self.get_with_budget(artifact, Some(&Cell::new(bytes)))
246            .map(|(path, _)| path)
247    }
248
249    fn get_with_budget(
250        &self,
251        artifact: &Artifact,
252        budget: Option<&Cell<u64>>,
253    ) -> Result<(PathBuf, ResolveOutcome)> {
254        let start = Instant::now();
255        let key = &artifact.key;
256        let _lock = self.layout.lock(key)?;
257
258        if let Some(entry) = self.layout.read_entry(key)? {
259            if let Some(path) = self.local_hit(artifact, &entry)? {
260                if budget.is_some_and(|budget| entry.bytes > budget.get()) {
261                    return Err(DatastoreError::TransferLimit);
262                }
263                return Ok((path, outcome(Layer::LocalDisk, entry.bytes, start, None)));
264            }
265        }
266        if self.offline {
267            return Err(DatastoreError::OfflineMiss { key: key.clone() });
268        }
269
270        let mirror_reason = match &self.mirror {
271            None => "no mirror configured".to_string(),
272            Some(mirror) => match self.fetch_from_mirror(artifact, mirror.as_ref(), budget) {
273                Ok(Some((path, bytes))) => {
274                    return Ok((path, outcome(Layer::Mirror, bytes, start, None)));
275                }
276                Ok(None) => format!("{} has no entry", mirror.location(key)),
277                Err(e @ DatastoreError::ContentRejected { .. }) => return Err(e),
278                Err(e @ DatastoreError::TransferLimit) => return Err(e),
279                Err(e) => e.to_string(),
280            },
281        };
282        if artifact.sources.is_empty() {
283            return Err(DatastoreError::ManualRequired {
284                key: key.clone(),
285                instructions: artifact.provenance.description.clone(),
286            });
287        }
288        if !self.allow_upstream {
289            return Err(DatastoreError::MirrorUnreachable {
290                key: key.clone(),
291                reason: mirror_reason,
292            });
293        }
294
295        let mut attempts = Vec::with_capacity(artifact.sources.len());
296        for (index, source) in artifact.sources.iter().enumerate() {
297            match self.fetch_from_upstream(artifact, index, budget) {
298                Ok((path, bytes)) => {
299                    return Ok((path, outcome(Layer::Upstream, bytes, start, Some(index))));
300                }
301                Err(e @ DatastoreError::TransferLimit) => return Err(e),
302                Err(
303                    e @ (DatastoreError::ContentRejected { .. }
304                    | DatastoreError::NoCredential { .. }
305                    | DatastoreError::CredentialRejected { .. }),
306                ) if artifact.sources.len() == 1 => {
307                    return Err(e);
308                }
309                Err(e) => attempts.push(format!("{}: {e}", sanitize(&source.url))),
310            }
311        }
312        Err(DatastoreError::AllSourcesFailed {
313            key: key.clone(),
314            attempts,
315        })
316    }
317
318    /// Read fully into memory. For small artifacts only.
319    pub fn get_bytes(&self, artifact: &Artifact) -> Result<Vec<u8>> {
320        Ok(std::fs::read(self.get(artifact)?)?)
321    }
322
323    /// Seed the cache from a file already on disk, validating it exactly as
324    /// a download would be. The file is copied, not moved.
325    pub fn import(&self, artifact: &Artifact, path: &Path) -> Result<PathBuf> {
326        let _lock = self.layout.lock(&artifact.key)?;
327        let mut file = std::fs::File::open(path)?;
328        let (tmp, digest, bytes) = self
329            .receive(artifact, None, |sink| {
330                std::io::copy(&mut file, sink)?;
331                Ok(true)
332            })?
333            .expect("a local file is always served");
334        let mut entry = IndexEntry::new(digest, bytes, Layer::LocalDisk);
335        entry.source = Some("local:import".into());
336        self.publish(artifact, tmp, entry)
337    }
338
339    /// Local path if already cached; never fetches and never rehashes.
340    pub fn peek(&self, key: &ArtifactKey) -> Option<PathBuf> {
341        let entry = self.layout.read_entry(key).ok()??;
342        let path = self.layout.blob_path(&entry.digest);
343        path.is_file().then_some(path)
344    }
345
346    pub fn contains(&self, key: &ArtifactKey) -> bool {
347        self.peek(key).is_some()
348    }
349
350    /// The index sidecar for `key`, if cached.
351    pub fn entry(&self, key: &ArtifactKey) -> Option<IndexEntry> {
352        let entry = self.layout.read_entry(key).ok()??;
353        self.layout
354            .blob_path(&entry.digest)
355            .is_file()
356            .then_some(entry)
357    }
358
359    pub fn remove(&self, key: &ArtifactKey) -> Result<()> {
360        let _lock = self.layout.lock(key)?;
361        let _store = self.layout.store_lock()?;
362        let Some(entry) = self.layout.read_entry(key)? else {
363            return Ok(());
364        };
365        self.layout.remove_entry(key)?;
366        if !self.digest_referenced(&entry.digest)? {
367            self.layout.remove_blob(&entry.digest)?;
368        }
369        Ok(())
370    }
371
372    pub fn keys(&self) -> Result<Vec<ArtifactKey>> {
373        self.layout.keys()
374    }
375
376    /// Bytes on disk across all blobs, orphans included; shared blobs count
377    /// once.
378    pub fn total_bytes(&self) -> Result<u64> {
379        Ok(self.layout.blobs()?.iter().map(|(_, len)| len).sum())
380    }
381
382    /// Rehash every blob; report keys whose blob is missing, hashes to a
383    /// different digest, or has a size that disagrees with the sidecar.
384    /// These are the index-integrity checks `get` makes on a hit; the
385    /// artifact's own `ContentCheck` needs an `Artifact` and is not run here.
386    pub fn verify(&self) -> Result<Vec<VerifyFailure>> {
387        let mut failures = Vec::new();
388        for (key, entry) in self.entries()? {
389            let path = self.layout.blob_path(&entry.digest);
390            if !path.is_file() {
391                failures.push(VerifyFailure {
392                    key,
393                    reason: format!("blob {} is missing", entry.digest),
394                    expected: entry.digest,
395                    actual: None,
396                });
397                continue;
398            }
399            let length = std::fs::metadata(&path)?.len();
400            let actual = digest_file(&path)?;
401            let reason = if actual != entry.digest {
402                format!("blob hashes to {actual}, index records {}", entry.digest)
403            } else if length != entry.bytes {
404                format!("blob is {length} bytes, index records {}", entry.bytes)
405            } else {
406                continue;
407            };
408            failures.push(VerifyFailure {
409                key,
410                expected: entry.digest,
411                actual: Some(actual),
412                reason,
413            });
414        }
415        Ok(failures)
416    }
417
418    /// Reclaim space: first orphan blobs, then the least-recently-fetched
419    /// keys until the store is within `max_bytes`. Explicit only: nothing is
420    /// evicted during `get`, so a path handed out earlier stays valid until
421    /// the caller chooses to run this. `tmp/` is left alone: a temp file's
422    /// age says nothing about whether its transfer is still running, so
423    /// leftovers from a crash are for the operator to remove.
424    pub fn gc(&self, max_bytes: u64) -> Result<Vec<ArtifactKey>> {
425        let (mut entries, sizes) = {
426            let _store = self.layout.store_lock()?;
427            let entries = self.entries()?;
428            let referenced: HashSet<&str> =
429                entries.iter().map(|(_, e)| e.digest.as_str()).collect();
430            let mut sizes: HashMap<String, u64> = HashMap::new();
431            for (digest, len) in self.layout.blobs()? {
432                if referenced.contains(digest.as_str()) {
433                    sizes.insert(digest, len);
434                } else {
435                    self.layout.remove_blob(&digest)?;
436                }
437            }
438            (entries, sizes)
439        };
440        entries.sort_by_key(|(_, e)| e.fetched_at);
441        let mut total: u64 = sizes.values().sum();
442        let mut removed = Vec::new();
443        for (key, snapshot) in &entries {
444            if total <= max_bytes {
445                break;
446            }
447            let _lock = self.layout.lock(key)?;
448            let _store = self.layout.store_lock()?;
449            // Re-read under the locks: the key may have been refetched or
450            // removed since the snapshot was taken.
451            let Some(entry) = self.layout.read_entry(key)? else {
452                continue;
453            };
454            if entry.digest != snapshot.digest {
455                continue;
456            }
457            self.layout.remove_entry(key)?;
458            if !self.digest_referenced(&entry.digest)? {
459                self.layout.remove_blob(&entry.digest)?;
460                total = total.saturating_sub(sizes.get(&entry.digest).copied().unwrap_or(0));
461            }
462            removed.push(key.clone());
463        }
464        Ok(removed)
465    }
466
467    /// Unique blob payload bytes, including orphans. Caller must exclude fills
468    /// when using this to verify a maintenance budget.
469    #[cfg(feature = "server")]
470    pub(crate) fn blob_bytes(&self) -> Result<u64> {
471        let _store = self.layout.store_lock()?;
472        self.layout
473            .blobs()?
474            .iter()
475            .try_fold(0u64, |total, (_, bytes)| {
476                total
477                    .checked_add(*bytes)
478                    .ok_or_else(|| DatastoreError::Config("cache byte count overflow".into()))
479            })
480    }
481
482    fn entries(&self) -> Result<Vec<(ArtifactKey, IndexEntry)>> {
483        let mut out = Vec::new();
484        for key in self.layout.keys()? {
485            if let Some(entry) = self.layout.read_entry(&key)? {
486                out.push((key, entry));
487            }
488        }
489        Ok(out)
490    }
491
492    fn digest_referenced(&self, digest: &str) -> Result<bool> {
493        Ok(self.entries()?.iter().any(|(_, e)| e.digest == digest))
494    }
495
496    /// A cached blob is served only after it has been rehashed against its
497    /// index entry and re-validated against the artifact's check. A blob
498    /// whose pin has moved on in the manifest is treated as a miss.
499    fn local_hit(&self, artifact: &Artifact, entry: &IndexEntry) -> Result<Option<PathBuf>> {
500        let path = self.layout.blob_path(&entry.digest);
501        if !path.is_file() {
502            return Ok(None);
503        }
504        if pinned_sha256s(&artifact.check)
505            .iter()
506            .any(|pin| *pin != entry.digest)
507        {
508            return Ok(None);
509        }
510        let corrupt = |what: &str, got: String| DatastoreError::ContentRejected {
511            key: artifact.key.clone(),
512            failure: CheckFailure {
513                check: format!("cached blob {what}"),
514                got: format!(
515                    "{got}; the blob is corrupt — run verify and remove the keys it reports"
516                ),
517            },
518        };
519        let length = std::fs::metadata(&path)?.len();
520        if length != entry.bytes {
521            return Err(corrupt(
522                "size",
523                format!("{length} bytes, but the index records {}", entry.bytes),
524            ));
525        }
526        let actual = digest_file(&path)?;
527        if actual != entry.digest {
528            return Err(corrupt(
529                "digest",
530                format!("{actual}, but the index records {}", entry.digest),
531            ));
532        }
533        validate(artifact, &path, entry.bytes, &actual)?;
534        Ok(Some(path))
535    }
536
537    fn fetch_from_mirror(
538        &self,
539        artifact: &Artifact,
540        mirror: &dyn MirrorRead,
541        budget: Option<&Cell<u64>>,
542    ) -> Result<Option<(PathBuf, u64)>> {
543        let Some((tmp, digest, bytes)) =
544            self.receive(artifact, budget, |sink| mirror.fetch(&artifact.key, sink))?
545        else {
546            return Ok(None);
547        };
548        let mut entry = IndexEntry::new(digest, bytes, Layer::Mirror);
549        entry.source = Some(mirror.location(&artifact.key));
550        let path = self.publish(artifact, tmp, entry)?;
551        Ok(Some((path, bytes)))
552    }
553
554    fn fetch_from_upstream(
555        &self,
556        artifact: &Artifact,
557        index: usize,
558        budget: Option<&Cell<u64>>,
559    ) -> Result<(PathBuf, u64)> {
560        let source = &artifact.sources[index];
561        let mut download = None;
562        let received = self.receive(artifact, budget, |sink| {
563            download = self.fetcher.request(source, sink)?;
564            Ok(download.is_some())
565        })?;
566        let (Some((tmp, digest, bytes)), Some(download)) = (received, download) else {
567            return Err(DatastoreError::Mirror("HTTP 404 Not Found".into()));
568        };
569        let mut entry = IndexEntry::new(digest, bytes, Layer::Upstream);
570        entry.source = Some(sanitize(&source.url));
571        entry.etag = download.etag;
572        entry.provider_identity = download.provider_identity;
573        let path = self.publish(artifact, tmp, entry)?;
574        Ok((path, bytes))
575    }
576
577    /// Run `producer` against a hashing temp file. The first 8 KB are shown
578    /// to `check_prefix` before the transfer continues, so a login page is
579    /// rejected before 12 GB of it arrive. Returns the temp file, digest and
580    /// byte count; `None` when the producer reported no object.
581    fn receive<F>(
582        &self,
583        artifact: &Artifact,
584        budget: Option<&Cell<u64>>,
585        producer: F,
586    ) -> Result<Option<(NamedTempFile, String, u64)>>
587    where
588        F: FnOnce(&mut dyn Write) -> Result<bool>,
589    {
590        let mut tmp = self.layout.tmp_file()?;
591        let mut writer = Receiver::new(tmp.as_file_mut(), &artifact.check);
592        writer.budget = budget;
593        let result = producer(&mut writer);
594        if writer.limit_exceeded {
595            return Err(DatastoreError::TransferLimit);
596        }
597        let served = match result {
598            Ok(served) => served,
599            Err(DatastoreError::Io(_)) if writer.rejected.is_some() => false,
600            Err(e) => return Err(e),
601        };
602        if let Some(failure) = writer.rejected.take() {
603            return Err(DatastoreError::ContentRejected {
604                key: artifact.key.clone(),
605                failure,
606            });
607        }
608        if !served {
609            return Ok(None);
610        }
611        writer
612            .finish_prefix()
613            .map_err(|failure| DatastoreError::ContentRejected {
614                key: artifact.key.clone(),
615                failure,
616            })?;
617        let (digest, bytes) = writer.finish();
618        tmp.as_file_mut().flush()?;
619        Ok(Some((tmp, digest, bytes)))
620    }
621
622    /// Validate, then move the temp file into place and write the sidecar,
623    /// under the store lock so reference scans see a consistent picture.
624    /// An existing blob at the same address that no longer hashes to it is
625    /// never silently overwritten: repair is explicit.
626    fn publish(
627        &self,
628        artifact: &Artifact,
629        tmp: NamedTempFile,
630        entry: IndexEntry,
631    ) -> Result<PathBuf> {
632        validate(artifact, tmp.path(), entry.bytes, &entry.digest)?;
633        let _store = self.layout.store_lock()?;
634        let path = self
635            .layout
636            .publish(tmp, &entry.digest)?
637            .ok_or_else(|| DatastoreError::ContentRejected {
638                key: artifact.key.clone(),
639                failure: CheckFailure {
640                    check: "cached blob digest".into(),
641                    got: format!(
642                        "an existing blob at {} no longer hashes to its address; run verify and remove the keys it reports",
643                        entry.digest
644                    ),
645                },
646            })?;
647        self.layout.write_entry(&artifact.key, &entry)?;
648        Ok(path)
649    }
650}
651
652/// Everything that must hold before bytes become a blob: the size pin, the
653/// digest pins, and the artifact's own check. The digest was computed while
654/// streaming, so `Sha256` nodes are compared here rather than rehashed.
655fn validate(artifact: &Artifact, path: &Path, bytes: u64, digest: &str) -> Result<()> {
656    let reject = |check: &str, got: String| DatastoreError::ContentRejected {
657        key: artifact.key.clone(),
658        failure: CheckFailure {
659            check: check.into(),
660            got,
661        },
662    };
663    if let Some(expected) = artifact.expected_bytes {
664        if expected != bytes {
665            return Err(reject(
666                "expected_bytes",
667                format!("{bytes} bytes, expected {expected}"),
668            ));
669        }
670    }
671    for pin in pinned_sha256s(&artifact.check) {
672        if pin != digest {
673            return Err(reject("Sha256", format!("digest {digest}, expected {pin}")));
674        }
675    }
676    without_sha256(&artifact.check)
677        .check_file(path)
678        .map_err(|failure| DatastoreError::ContentRejected {
679            key: artifact.key.clone(),
680            failure,
681        })
682}
683
684/// Every `Sha256` pin in the tree, in order. Conflicting pins can never all
685/// match, so an artifact carrying two different ones is rejected outright.
686fn pinned_sha256s(check: &ContentCheck) -> Vec<&str> {
687    match check {
688        ContentCheck::Sha256(digest) => vec![digest],
689        ContentCheck::All(checks) => checks.iter().flat_map(pinned_sha256s).collect(),
690        _ => Vec::new(),
691    }
692}
693
694fn without_sha256(check: &ContentCheck) -> ContentCheck {
695    match check {
696        ContentCheck::Sha256(_) => ContentCheck::None,
697        ContentCheck::All(checks) => ContentCheck::All(checks.iter().map(without_sha256).collect()),
698        other => other.clone(),
699    }
700}
701
702fn outcome(
703    layer: Layer,
704    bytes: u64,
705    start: Instant,
706    source_index: Option<usize>,
707) -> ResolveOutcome {
708    ResolveOutcome {
709        layer,
710        bytes,
711        duration: start.elapsed(),
712        source_index,
713    }
714}
715
716/// Hashes and counts what passes through, and shows the first `PREFIX_BYTES`
717/// to the artifact's check before letting the rest of the body in.
718struct Receiver<'a> {
719    inner: &'a mut std::fs::File,
720    check: &'a ContentCheck,
721    hasher: Sha256,
722    bytes: u64,
723    head: Vec<u8>,
724    prefix_checked: bool,
725    rejected: Option<CheckFailure>,
726    budget: Option<&'a Cell<u64>>,
727    limit_exceeded: bool,
728}
729
730impl<'a> Receiver<'a> {
731    fn new(inner: &'a mut std::fs::File, check: &'a ContentCheck) -> Self {
732        Self {
733            inner,
734            check,
735            hasher: Sha256::new(),
736            bytes: 0,
737            head: Vec::with_capacity(PREFIX_BYTES),
738            prefix_checked: false,
739            rejected: None,
740            budget: None,
741            limit_exceeded: false,
742        }
743    }
744
745    fn check_prefix(&mut self) -> std::io::Result<()> {
746        self.prefix_checked = true;
747        if let Err(failure) = self.check.check_prefix(&self.head) {
748            let message = format!("{}: {}", failure.check, failure.got);
749            self.rejected = Some(failure);
750            return Err(std::io::Error::other(message));
751        }
752        Ok(())
753    }
754
755    /// A body shorter than the prefix window is checked at the end.
756    fn finish_prefix(&mut self) -> std::result::Result<(), CheckFailure> {
757        if !self.prefix_checked {
758            self.prefix_checked = true;
759            self.check.check_prefix(&self.head)?;
760        }
761        Ok(())
762    }
763
764    fn finish(self) -> (String, u64) {
765        (format!("{:x}", self.hasher.finalize()), self.bytes)
766    }
767}
768
769impl Write for Receiver<'_> {
770    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
771        if let Some(budget) = self.budget {
772            let remaining = budget.get().checked_sub(buf.len() as u64);
773            if self.limit_exceeded || remaining.is_none() {
774                self.limit_exceeded = true;
775                return Err(std::io::Error::other("fill byte reservation exhausted"));
776            }
777            // Charge before writing; never refund on retry. This conservatively
778            // covers partial writes and blobs orphaned by publication failures.
779            budget.set(remaining.unwrap());
780        }
781        if let Some(failure) = &self.rejected {
782            return Err(std::io::Error::other(format!(
783                "{}: {}",
784                failure.check, failure.got
785            )));
786        }
787        if !self.prefix_checked {
788            let take = (PREFIX_BYTES - self.head.len()).min(buf.len());
789            self.head.extend_from_slice(&buf[..take]);
790            if self.head.len() == PREFIX_BYTES {
791                self.check_prefix()?;
792            }
793        }
794        self.inner.write_all(buf)?;
795        self.hasher.update(buf);
796        self.bytes += buf.len() as u64;
797        Ok(buf.len())
798    }
799
800    fn flush(&mut self) -> std::io::Result<()> {
801        self.inner.flush()
802    }
803}
804
805impl IndexEntry {
806    fn new(digest: String, bytes: u64, layer: Layer) -> Self {
807        Self {
808            digest,
809            bytes,
810            fetched_at: now_unix(),
811            source: None,
812            etag: None,
813            provider_identity: None,
814            layer,
815        }
816    }
817}
818
819#[cfg(all(test, feature = "server"))]
820mod limited_transfer_tests {
821    use super::*;
822
823    #[test]
824    fn cumulative_streaming_limit_covers_unknown_sizes_retries_and_wrapped_errors() {
825        let root = tempfile::tempdir().unwrap();
826        let store = Datastore::builder()
827            .cache_root(root.path().to_owned())
828            .build()
829            .unwrap();
830        let artifact = Artifact::new(ArtifactKey::new("large").unwrap(), vec![])
831            .with_check(ContentCheck::None);
832        let budget = Cell::new(10);
833        let first = store.receive(&artifact, Some(&budget), |sink| {
834            sink.write_all(b"123456")?;
835            Err(DatastoreError::Mirror("failed transfer".into()))
836        });
837        assert!(first.is_err());
838        assert_eq!(budget.get(), 4, "retries do not refund materialized bytes");
839        let second = store.receive(&artifact, Some(&budget), |sink| {
840            sink.write_all(b"789")?;
841            assert!(sink.write_all(b"xx").is_err());
842            // Fetchers can wrap the sink error. Still return the explicit limit error.
843            Err(DatastoreError::Mirror("wrapped write failure".into()))
844        });
845        assert!(matches!(second, Err(DatastoreError::TransferLimit)));
846        assert_eq!(budget.get(), 1);
847        assert_eq!(
848            std::fs::read_dir(root.path().join("tmp")).unwrap().count(),
849            0
850        );
851        assert_eq!(store.blob_bytes().unwrap(), 0);
852        let unbounded = store
853            .receive(&artifact, None, |sink| {
854                sink.write_all(&[0; 32])?;
855                Ok(true)
856            })
857            .unwrap()
858            .unwrap();
859        assert_eq!(unbounded.2, 32);
860    }
861
862    #[test]
863    fn local_hits_obey_fill_limit_but_normal_get_does_not_evict() {
864        let root = tempfile::tempdir().unwrap();
865        let store = Datastore::builder()
866            .cache_root(root.path().to_owned())
867            .max_bytes(0)
868            .build()
869            .unwrap();
870        let artifact = Artifact::new(ArtifactKey::new("existing").unwrap(), vec![])
871            .with_check(ContentCheck::None);
872        let input = tempfile::NamedTempFile::new().unwrap();
873        std::fs::write(input.path(), b"payload").unwrap();
874        let path = store.import(&artifact, input.path()).unwrap();
875        assert!(matches!(
876            store.get_limited(&artifact, 6),
877            Err(DatastoreError::TransferLimit)
878        ));
879        assert_eq!(store.get(&artifact).unwrap(), path);
880        assert!(path.exists());
881    }
882}