trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
//! Installing, updating and inspecting the runtime content cache (ADR-0064
//! PHASE_3, epic #8378 PR-C).
//!
//! Why: `trusty_common::content::resolve` verifies and serves an installed
//! bundle but never installs one. This module is the writer behind
//! `tm content install --from`, `tm content update` and `tm content status`.
//! The fallback is runtime-only (owner ruling on #8974): nothing is compiled
//! in, so with no cache and no network every error names
//! [`INSTALL_HINT`].
//! What: every write holds an exclusive file lock on `<cache>/.update.lock`
//! for its whole read-fetch-write span, checks the bytes against the release's
//! required sha256 sidecar, verifies them with the same `content::resolve` the runtime
//! uses (in a staging directory inside the cache), stores `<tag>.tar.gz`
//! atomically, and only then swaps `content-lock.toml`. A failure at any step
//! leaves the previous pin and its bundle in place.
//! Test: `content::bundle_cache::tests` (`bundle_cache_tests.rs`).
//!
//! # Spec References
//! - ADR-0064 decision 5, PHASE_3 acceptance criteria (i)-(iv):
//!   `docs/adr/0064-instructional-content-tracked-separately-from-code.md`

use std::fs::File;
use std::io::Read;
use std::path::{Path, PathBuf};

use trusty_common::content::{
    self, ContentError, ContentLock, ContentSource, DevOverride, LOCK_FILE_NAME, MAX_BUNDLE_BYTES,
    ResolveOptions,
};
use trusty_common::integrity::{IntegrityError, Sha256Digest};

pub use super::release_source::{
    CONTENT_REPO, FetchError, GithubReleases, Release, ReleaseSource, Retrying, github_source,
};

/// The file every writer locks exclusively, inside the cache directory.
pub const UPDATE_LOCK_FILE: &str = ".update.lock";

/// The command an operator runs when no network is reachable.
pub const INSTALL_HINT: &str = "tm content install --from <dir>/<tag>.tar.gz";

/// The manual install, for `tag` when known (#9396).
///
/// Why: `gh release download` reads the release assets without the GitHub
/// API calls that a rate limit or an API outage refuses, so it is the path
/// that still works when `tm content update` does not.
/// What: `gh release download <tag> --repo` [`CONTENT_REPO`], then
/// `tm content install --from <dir>/<tag>.tar.gz`; `<tag>` stays a
/// placeholder when the tag is unknown.
/// Test: `a_persistent_5xx_names_the_manual_install`,
/// `a_missing_sidecar_names_the_manual_install`.
pub fn manual_install(tag: Option<&str>) -> String {
    let tag = tag.unwrap_or("<tag>");
    format!(
        "install by hand: `gh release download {tag} --repo {CONTENT_REPO}`, then \
         `tm content install --from <dir>/{tag}.tar.gz`"
    )
}

/// Suffix of the sha256 sidecar the release ships beside each bundle.
pub const SIDECAR_SUFFIX: &str = ".sha256";

/// Largest sidecar read; a real one is one 90-byte line.
pub const MAX_SIDECAR_BYTES: u64 = 4096;

/// What remains in use when an update cannot reach its release.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Fallback {
    /// A verified installed bundle, which keeps serving.
    Cached(String),
    /// Nothing verified is installed.
    None,
}

impl std::fmt::Display for Fallback {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Cached(tag) => write!(f, "the installed {tag} is verified and stays in use"),
            // #9396: the error that carries this names the manual install.
            Self::None => write!(f, "no verified content bundle is installed"),
        }
    }
}

/// Every way installing or updating the content cache fails.
///
/// Why: the fix differs per cause, and none of them may be answered by
/// pinning bytes that failed a check (owner ruling on #8974: fail closed).
/// What: `#[non_exhaustive]`; resolver failures pass through as `Content`.
/// Test: one error-arm test per variant in `bundle_cache_tests.rs`, among them
/// `install_refuses_an_unparseable_sidecar`,
/// `install_refuses_a_bundle_that_names_no_tag`,
/// `install_refuses_an_oversized_sidecar`,
/// `update_refuses_a_release_without_a_sidecar`,
/// `update_with_only_prereleases_published_has_no_release`; version order in
/// `latest_release_compares_versions_not_strings`.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum CacheError {
    /// The resolver refused the bundle or the lock.
    #[error(transparent)]
    Content(#[from] ContentError),
    /// The sidecar is not a sha256 line.
    #[error("the sha256 sidecar {origin} is invalid: {source}")]
    Sidecar {
        /// The sidecar file or URL.
        origin: String,
        /// Why it failed to parse.
        #[source]
        source: IntegrityError,
    },
    /// An offline install found no sidecar beside the bundle.
    #[error(
        "no sha256 sidecar at {}: install the `<bundle>{SIDECAR_SUFFIX}` file the release \
         ships beside the bundle",
        path.display()
    )]
    SidecarMissing {
        /// Where the sidecar was looked for.
        path: PathBuf,
    },
    /// The bundle's bytes differ from the sidecar's digest.
    #[error(
        "refusing {origin}: the sidecar pins sha256 {expected}, but the bundle hashes to {actual}"
    )]
    ChecksumMismatch {
        /// The bundle file or URL.
        origin: String,
        /// The sidecar's digest.
        expected: Sha256Digest,
        /// The digest of the bytes received.
        actual: Sha256Digest,
    },
    /// The release carries a bundle but no sha256 sidecar; the sidecar is required.
    #[error(
        "refusing {tag}: the release has no sha256 sidecar at {url}, and a bundle is never \
         pinned without one; {fallback}; retry `tm content update` later, or {}",
        manual_install(Some(tag))
    )]
    SidecarNotPublished {
        /// The release tag.
        tag: String,
        /// The URL that answered 404.
        url: String,
        /// What stays in use.
        fallback: Fallback,
    },
    /// The release, or its bundle, does not exist upstream.
    #[error(
        "content release {tag} was not found upstream ({url}); {fallback}; run \
         `tm content update` for the newest release, or {}",
        manual_install(None)
    )]
    TagNotFound {
        /// The requested tag.
        tag: String,
        /// The URL that answered 404.
        url: String,
        /// What stays in use.
        fallback: Fallback,
    },
    /// The release could not be reached.
    #[error(
        "could not reach {url}: {reason}; {fallback}; retry `tm content update`, or {}",
        manual_install(tag.as_deref())
    )]
    Network {
        /// The URL that failed.
        url: String,
        /// The transport failure.
        reason: String,
        /// The release being fetched, once known (#9396).
        tag: Option<String>,
        /// What stays in use.
        fallback: Fallback,
    },
    /// A `content-v*` git tag upstream is not a `content-vX.Y.Z` version.
    #[error("upstream has a content tag {tag:?} that is not a content-vX.Y.Z version; {fallback}")]
    UnparsableTag {
        /// The tag as listed.
        tag: String,
        /// What stays in use.
        fallback: Fallback,
    },
    /// The lock pins this tag with other bytes than the release now carries.
    #[error(
        "refusing {tag}: the lock pins sha256 {pinned}, but the release now hashes to {upstream}; \
         a published content release never changes"
    )]
    PinConflict {
        /// The tag.
        tag: String,
        /// The digest the lock pins.
        pinned: Sha256Digest,
        /// The digest offered now.
        upstream: Sha256Digest,
    },
    /// No published content release: none that is not a draft or a pre-release.
    #[error(
        "no published content-v* release upstream (drafts and pre-releases are never installed)"
    )]
    NoReleases,
    /// Neither the sidecar nor the bundle's file name names a release tag.
    #[error(
        "cannot tell which release {} is: its sidecar names no content-vX.Y.Z.tar.gz file, \
         and neither does its own file name",
        path.display()
    )]
    UnknownTag {
        /// The bundle file.
        path: PathBuf,
    },
    /// A local file is over its size cap.
    #[error("{} is {len} bytes, over the {cap}-byte cap", path.display())]
    TooLarge {
        /// The file.
        path: PathBuf,
        /// Its size.
        len: u64,
        /// The cap.
        cap: u64,
    },
    /// A local read or write failed.
    #[error("could not {action} {}: {source}", path.display())]
    Io {
        /// What was being done.
        action: &'static str,
        /// The path.
        path: PathBuf,
        /// The failure.
        #[source]
        source: std::io::Error,
    },
}

/// What an install or update did.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UpdateAction {
    /// A new pin was written.
    Installed,
    /// The pinned bundle was missing or broken and was fetched again.
    Repaired,
    /// The pinned bundle verified; nothing was written.
    AlreadyCurrent,
}

/// The pin in force after an install or update.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateOutcome {
    /// The pinned tag.
    pub tag: String,
    /// The pinned sha256.
    pub sha256: Sha256Digest,
    /// What was done.
    pub action: UpdateAction,
}

/// Installs a bundle from a local file with no network (ADR-0064 (ii)).
///
/// Why: an air-gapped host has no other way in, and the runtime-only ruling
/// leaves no compiled fallback.
/// What: reads `<bundle>` and `<bundle>.sha256` (required; a missing sidecar
/// is [`CacheError::SidecarMissing`]), takes the tag from the sidecar's file
/// name or else the bundle's, and commits it through [`commit`]. A broken lock
/// is replaced — this is the offline repair path.
/// Test: `install_from_a_file_pins_its_tag_and_sha256`,
/// `install_refuses_a_bundle_that_does_not_match_its_sidecar`,
/// `install_without_a_sidecar_is_refused`.
pub fn install_from_file(cache: &Path, bundle: &Path) -> Result<UpdateOutcome, CacheError> {
    let bytes = read_capped(bundle, MAX_BUNDLE_BYTES)?;
    let sidecar_path = sidecar_path(bundle);
    let sidecar = match read_capped(&sidecar_path, MAX_SIDECAR_BYTES) {
        Err(CacheError::Io { source, .. }) if source.kind() == std::io::ErrorKind::NotFound => {
            return Err(CacheError::SidecarMissing { path: sidecar_path });
        }
        other => other?,
    };
    let origin = sidecar_path.display().to_string();
    let expected = parse_sidecar(&sidecar, &origin)?;
    let tag = tag_from_sidecar(&sidecar)
        .or_else(|| tag_from_file_name(bundle))
        .ok_or_else(|| CacheError::UnknownTag {
            path: bundle.to_path_buf(),
        })?;
    let _guard = UpdateGuard::acquire(cache)?;
    let current = read_lock(cache).ok().flatten();
    let action = pin_action(current.as_ref(), &tag);
    let lock = commit(
        cache,
        &tag,
        &bytes,
        &expected,
        current.as_ref(),
        &bundle.display().to_string(),
    )?;
    Ok(outcome(lock, action))
}

/// Fetches and pins a content release (`tm content update`).
///
/// Why: #8389 and owner ruling (Bob item 207) — no flag installs the newest
/// published release and re-pins to it; `--content-ref` pins one exactly. The
/// pin moves only here: between updates nothing changes it.
/// What: under the update lock, picks the target — `content_ref`, else the
/// newest release (found through `git/matching-refs`, #9036) that is neither a draft nor a
/// pre-release. A failed listing is an error that names what stays in use;
/// it never reads as "already current". A target whose installed bundle
/// already verifies is [`UpdateAction::AlreadyCurrent`]. Otherwise fetches the
/// bundle and its required sidecar and commits them through [`commit`]. A
/// lock that does not parse is an error unless `content_ref` replaces it.
/// Test: `update_with_no_lock_installs_the_latest_release`,
/// `update_without_a_ref_moves_the_pin_to_the_newest_release`,
/// `update_refuses_a_bundle_whose_sha256_differs_from_the_sidecar`,
/// `update_refuses_a_release_without_a_sidecar`,
/// `update_to_a_tag_missing_upstream_is_a_named_error`,
/// `update_offline_with_no_cache_names_the_install_command`,
/// `update_offline_with_a_verified_cache_fails_and_keeps_the_pin`,
/// `update_never_installs_a_draft_or_a_pre_release`,
/// `concurrent_updates_serialise_on_the_file_lock`.
pub fn update<S: ReleaseSource + ?Sized>(
    cache: &Path,
    source: &S,
    content_ref: Option<&str>,
) -> Result<UpdateOutcome, CacheError> {
    if let Some(tag) = content_ref {
        content::validate_tag(tag)?;
    }
    let _guard = UpdateGuard::acquire(cache)?;
    update_locked(cache, source, content_ref)
}

/// Installs the newest release when, and only when, no lock is present
/// (#9396: tm's first use, ADR-0064 decision 5).
///
/// Why: a fresh install has no `content-lock.toml`, so nothing composes until
/// a release is pinned. Two first uses can race, and an operator's own
/// `tm content update` can land between a caller's "no lock" check and this
/// call; the pin that is already there must win.
/// What: under the update lock, a present lock file (valid or not) answers
/// `Ok(None)` and nothing is fetched or written. Otherwise this is
/// [`update`] with no `content_ref`: the same release selection, sidecar
/// check, verification and atomic writes.
/// Test: `install_if_missing_keeps_a_lock_written_while_it_waited`,
/// `concurrent_first_use_leaves_one_valid_lock`.
pub fn install_if_missing<S: ReleaseSource + ?Sized>(
    cache: &Path,
    source: &S,
) -> Result<Option<UpdateOutcome>, CacheError> {
    let _guard = UpdateGuard::acquire(cache)?;
    // #9396: checked under the lock, so a pin written while this waited wins.
    if cache.join(LOCK_FILE_NAME).symlink_metadata().is_ok() {
        return Ok(None);
    }
    update_locked(cache, source, None).map(Some)
}

/// The body of [`update`], run while the caller holds the update lock.
fn update_locked<S: ReleaseSource + ?Sized>(
    cache: &Path,
    source: &S,
    content_ref: Option<&str>,
) -> Result<UpdateOutcome, CacheError> {
    let current = match read_lock(cache) {
        Ok(lock) => lock,
        Err(_) if content_ref.is_some() => None,
        Err(e) => return Err(e),
    };
    let verified = verified_tag(cache);
    let fallback = verified
        .clone()
        .map_or(Fallback::None, |(tag, _)| Fallback::Cached(tag));
    let target = match content_ref {
        Some(tag) => tag.to_owned(),
        None => latest_tag(source, &fallback)?,
    };
    if let Some((tag, sha256)) = verified.filter(|(tag, _)| *tag == target) {
        return Ok(UpdateOutcome {
            tag,
            sha256,
            action: UpdateAction::AlreadyCurrent,
        });
    }
    let bundle_name = format!("{target}.tar.gz");
    let sidecar_name = format!("{bundle_name}{SIDECAR_SUFFIX}");
    let bytes =
        fetch(source, &target, &bundle_name, MAX_BUNDLE_BYTES, &fallback)?.ok_or_else(|| {
            CacheError::TagNotFound {
                tag: target.clone(),
                url: source.asset_url(&target, &bundle_name),
                fallback: fallback.clone(),
            }
        })?;
    // #8389: the sidecar is required; a release without one is refused.
    let sidecar =
        fetch(source, &target, &sidecar_name, MAX_SIDECAR_BYTES, &fallback)?.ok_or_else(|| {
            CacheError::SidecarNotPublished {
                tag: target.clone(),
                url: source.asset_url(&target, &sidecar_name),
                fallback: fallback.clone(),
            }
        })?;
    let expected = parse_sidecar(&sidecar, &sidecar_name)?;
    let action = pin_action(current.as_ref(), &target);
    let lock = commit(
        cache,
        &target,
        &bytes,
        &expected,
        current.as_ref(),
        &bundle_name,
    )?;
    Ok(outcome(lock, action))
}

/// Verifies `bytes` and pins them: the one write path for install and update.
///
/// Why: the bundle must be on disk before the lock names it, so a crash or a
/// failed write leaves the previous pin resolvable; and only bytes the
/// runtime resolver itself accepts may be pinned.
/// What: checks the sidecar digest, refuses a pin conflict, verifies the
/// candidate with `content::resolve` in a staging directory inside the cache
/// (tag, sha256, caps, entries, manifest, schema major), writes
/// `<cache>/<tag>.tar.gz` atomically, then stores the lock atomically.
/// Test: `a_failed_bundle_write_leaves_the_previous_pin_in_force`,
/// `install_refuses_a_newer_schema_major_and_leaves_the_cache_untouched`.
fn commit(
    cache: &Path,
    tag: &str,
    bytes: &[u8],
    expected: &Sha256Digest,
    current: Option<&ContentLock>,
    origin: &str,
) -> Result<ContentLock, CacheError> {
    let actual = Sha256Digest::of_bytes(bytes);
    if &actual != expected {
        return Err(CacheError::ChecksumMismatch {
            origin: origin.to_owned(),
            expected: expected.clone(),
            actual,
        });
    }
    if let Some(pinned) = current.filter(|lock| lock.tag() == tag)
        && pinned.sha256() != &actual
    {
        return Err(CacheError::PinConflict {
            tag: tag.to_owned(),
            pinned: pinned.sha256().clone(),
            upstream: actual,
        });
    }
    let lock = ContentLock::new(tag, actual)?;
    let staging = tempfile::Builder::new()
        .prefix(".staging-")
        .tempdir_in(cache)
        .map_err(|source| io_err("create a staging directory in", cache, source))?;
    let staged = staging.path().join(lock.bundle_file_name());
    trusty_common::atomic_file::write_atomic(&staged, bytes)
        .map_err(|source| io_err("write", &staged, source))?;
    lock.store(&staging.path().join(LOCK_FILE_NAME))?;
    content::resolve(&ResolveOptions::new(staging.path(), DevOverride::Off))?;
    // #8378: the bundle is stored BEFORE the lock names it.
    let bundle = cache.join(lock.bundle_file_name());
    trusty_common::atomic_file::write_atomic(&bundle, bytes)
        .map_err(|source| io_err("write", &bundle, source))?;
    lock.store(&cache.join(LOCK_FILE_NAME))?;
    Ok(lock)
}

/// An exclusive lock on `<cache>/.update.lock`, released on drop.
///
/// Why: an update reads the pin, fetches, then writes; two unserialised
/// updates lose one of the writes — a no-flag update could put back the pin a
/// concurrent `--content-ref` had just replaced.
/// Test: `concurrent_updates_serialise_on_the_file_lock`.
struct UpdateGuard(#[allow(dead_code)] File);

impl UpdateGuard {
    fn acquire(cache: &Path) -> Result<Self, CacheError> {
        std::fs::create_dir_all(cache).map_err(|source| io_err("create", cache, source))?;
        let path = cache.join(UPDATE_LOCK_FILE);
        let file = std::fs::OpenOptions::new()
            .create(true)
            .truncate(false)
            .write(true)
            .open(&path)
            .map_err(|source| io_err("open", &path, source))?;
        file.lock()
            .map_err(|source| io_err("lock", &path, source))?;
        Ok(Self(file))
    }
}

/// The lock in the cache: `Ok(None)` when none is installed.
fn read_lock(cache: &Path) -> Result<Option<ContentLock>, CacheError> {
    match ContentLock::load(&cache.join(LOCK_FILE_NAME)) {
        Ok(lock) => Ok(Some(lock)),
        Err(ContentError::NotInstalled { .. }) => Ok(None),
        Err(e) => Err(e.into()),
    }
}

/// The installed pin, when its bundle verifies.
fn verified_tag(cache: &Path) -> Option<(String, Sha256Digest)> {
    match content::resolve(&ResolveOptions::new(cache, DevOverride::Off))
        .ok()?
        .source()
    {
        ContentSource::Installed { tag, sha256, .. } => Some((tag.clone(), sha256.clone())),
        _ => None,
    }
}

fn pin_action(current: Option<&ContentLock>, tag: &str) -> UpdateAction {
    match current {
        Some(lock) if lock.tag() == tag => UpdateAction::Repaired,
        _ => UpdateAction::Installed,
    }
}

fn outcome(lock: ContentLock, action: UpdateAction) -> UpdateOutcome {
    UpdateOutcome {
        tag: lock.tag().to_owned(),
        sha256: lock.sha256().clone(),
        action,
    }
}

/// The newest published release: not a draft, not a pre-release (by flag or
/// by a SemVer pre-release suffix), compared by version, not by string.
///
/// #9036: lists only the `content-v*` tags (one request), then reads the
/// release of the highest stable one (one more). A tag that is not a
/// `content-vX.Y.Z[-pre]` version is an error, never skipped: a listing this
/// code cannot read in full must not pick a "latest" from part of it. A
/// highest tag whose release is flagged pre-release is passed over for the
/// next one down. A draft, or a tag with no release, is an error, not passed
/// over: GitHub's `releases/tags/{tag}` answers 404 for both.
fn latest_tag<S: ReleaseSource + ?Sized>(
    source: &S,
    fallback: &Fallback,
) -> Result<String, CacheError> {
    let network = |e: FetchError| CacheError::Network {
        url: e.url,
        reason: e.reason,
        tag: None,
        fallback: fallback.clone(),
    };
    let mut stable = Vec::new();
    for tag in source.content_tags().map_err(network)? {
        let version = release_version(&tag).ok_or_else(|| CacheError::UnparsableTag {
            tag: tag.clone(),
            fallback: fallback.clone(),
        })?;
        if version.pre.is_empty() {
            stable.push((version, tag));
        }
    }
    stable.sort_by(|a, b| b.0.cmp(&a.0));
    for (_, tag) in stable {
        let release = source.release(&tag).map_err(network)?;
        if !release.draft && !release.prerelease {
            return Ok(tag);
        }
    }
    Err(CacheError::NoReleases)
}

fn release_version(tag: &str) -> Option<semver::Version> {
    content::validate_tag(tag).ok()?;
    semver::Version::parse(tag.strip_prefix(content::TAG_PREFIX)?).ok()
}

/// One release asset: `Ok(None)` when upstream answers that it does not exist.
fn fetch<S: ReleaseSource + ?Sized>(
    source: &S,
    tag: &str,
    file: &str,
    max_bytes: u64,
    fallback: &Fallback,
) -> Result<Option<Vec<u8>>, CacheError> {
    source
        .asset(tag, file, max_bytes)
        .map_err(|e| CacheError::Network {
            url: e.url,
            reason: e.reason,
            tag: Some(tag.to_owned()),
            fallback: fallback.clone(),
        })
}

fn parse_sidecar(bytes: &[u8], origin: &str) -> Result<Sha256Digest, CacheError> {
    let text = String::from_utf8_lossy(bytes);
    Sha256Digest::from_sidecar(&text).map_err(|source| CacheError::Sidecar {
        origin: origin.to_owned(),
        source,
    })
}

/// The tag a `sha256sum` line names: `<hex>  [*]content-vX.Y.Z.tar.gz`.
fn tag_from_sidecar(bytes: &[u8]) -> Option<String> {
    let text = String::from_utf8_lossy(bytes);
    let name = text.split_whitespace().nth(1)?.trim_start_matches('*');
    tag_from_file_name(Path::new(name))
}

fn tag_from_file_name(path: &Path) -> Option<String> {
    let tag = path.file_name()?.to_str()?.strip_suffix(".tar.gz")?;
    content::validate_tag(tag).ok().map(|()| tag.to_owned())
}

fn sidecar_path(bundle: &Path) -> PathBuf {
    let mut name = bundle.as_os_str().to_owned();
    name.push(SIDECAR_SUFFIX);
    PathBuf::from(name)
}

fn read_capped(path: &Path, cap: u64) -> Result<Vec<u8>, CacheError> {
    let file = File::open(path).map_err(|source| io_err("read", path, source))?;
    let mut bytes = Vec::new();
    file.take(cap.saturating_add(1))
        .read_to_end(&mut bytes)
        .map_err(|source| io_err("read", path, source))?;
    let len = bytes.len() as u64;
    if len > cap {
        return Err(CacheError::TooLarge {
            path: path.to_path_buf(),
            len,
            cap,
        });
    }
    Ok(bytes)
}

fn io_err(action: &'static str, path: &Path, source: std::io::Error) -> CacheError {
    CacheError::Io {
        action,
        path: path.to_path_buf(),
        source,
    }
}

#[cfg(test)]
#[path = "bundle_cache_tests.rs"]
mod tests;