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,
};
pub const UPDATE_LOCK_FILE: &str = ".update.lock";
pub const INSTALL_HINT: &str = "tm content install --from <dir>/<tag>.tar.gz";
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`"
)
}
pub const SIDECAR_SUFFIX: &str = ".sha256";
pub const MAX_SIDECAR_BYTES: u64 = 4096;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Fallback {
Cached(String),
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"),
Self::None => write!(f, "no verified content bundle is installed"),
}
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum CacheError {
#[error(transparent)]
Content(#[from] ContentError),
#[error("the sha256 sidecar {origin} is invalid: {source}")]
Sidecar {
origin: String,
#[source]
source: IntegrityError,
},
#[error(
"no sha256 sidecar at {}: install the `<bundle>{SIDECAR_SUFFIX}` file the release \
ships beside the bundle",
path.display()
)]
SidecarMissing {
path: PathBuf,
},
#[error(
"refusing {origin}: the sidecar pins sha256 {expected}, but the bundle hashes to {actual}"
)]
ChecksumMismatch {
origin: String,
expected: Sha256Digest,
actual: Sha256Digest,
},
#[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 {
tag: String,
url: String,
fallback: Fallback,
},
#[error(
"content release {tag} was not found upstream ({url}); {fallback}; run \
`tm content update` for the newest release, or {}",
manual_install(None)
)]
TagNotFound {
tag: String,
url: String,
fallback: Fallback,
},
#[error(
"could not reach {url}: {reason}; {fallback}; retry `tm content update`, or {}",
manual_install(tag.as_deref())
)]
Network {
url: String,
reason: String,
tag: Option<String>,
fallback: Fallback,
},
#[error("upstream has a content tag {tag:?} that is not a content-vX.Y.Z version; {fallback}")]
UnparsableTag {
tag: String,
fallback: Fallback,
},
#[error(
"refusing {tag}: the lock pins sha256 {pinned}, but the release now hashes to {upstream}; \
a published content release never changes"
)]
PinConflict {
tag: String,
pinned: Sha256Digest,
upstream: Sha256Digest,
},
#[error(
"no published content-v* release upstream (drafts and pre-releases are never installed)"
)]
NoReleases,
#[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 {
path: PathBuf,
},
#[error("{} is {len} bytes, over the {cap}-byte cap", path.display())]
TooLarge {
path: PathBuf,
len: u64,
cap: u64,
},
#[error("could not {action} {}: {source}", path.display())]
Io {
action: &'static str,
path: PathBuf,
#[source]
source: std::io::Error,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UpdateAction {
Installed,
Repaired,
AlreadyCurrent,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateOutcome {
pub tag: String,
pub sha256: Sha256Digest,
pub action: UpdateAction,
}
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))
}
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)
}
pub fn install_if_missing<S: ReleaseSource + ?Sized>(
cache: &Path,
source: &S,
) -> Result<Option<UpdateOutcome>, CacheError> {
let _guard = UpdateGuard::acquire(cache)?;
if cache.join(LOCK_FILE_NAME).symlink_metadata().is_ok() {
return Ok(None);
}
update_locked(cache, source, None).map(Some)
}
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(),
}
})?;
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))
}
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))?;
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)
}
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))
}
}
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()),
}
}
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,
}
}
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()
}
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,
})
}
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;