use std::path::Path;
use semver::Version;
use serde::Deserialize;
use serde::Serialize;
use crate::CodePushError;
use crate::durable;
use crate::layout::ChannelIndex;
use crate::layout::INDEX_FORMAT_VERSION;
use crate::layout::ObjectKey;
use crate::layout::ObjectKind;
use crate::layout::encode_component;
use crate::manifest::BundleManifest;
use crate::manifest::hash_bytes;
use crate::manifest::read_container;
use crate::provenance::ProvenancePolicy;
use crate::provenance::ReleaseApprovals;
use crate::provenance::VerifiedSource;
use crate::signing::ReleaseSignature;
use crate::signing::TrustedKeys;
use crate::store::ReleaseStore;
pub trait Transport {
fn fetch(&mut self, url: &str) -> Result<Vec<u8>, CodePushError>;
fn fetch_within(&mut self, url: &str, max_bytes: u64) -> Result<Vec<u8>, CodePushError> {
let body = self.fetch(url)?;
if body.len() as u64 > max_bytes {
return Err(CodePushError::Malformed(format!(
"{url} is {} bytes, over the {max_bytes} byte limit",
body.len()
)));
}
Ok(body)
}
}
impl<T: Transport + ?Sized> Transport for &mut T {
fn fetch(&mut self, url: &str) -> Result<Vec<u8>, CodePushError> {
(**self).fetch(url)
}
fn fetch_within(&mut self, url: &str, max_bytes: u64) -> Result<Vec<u8>, CodePushError> {
(**self).fetch_within(url, max_bytes)
}
}
pub struct VerifiedDownload {
pub container: Vec<u8>,
pub manifest: BundleManifest,
pub signature: ReleaseSignature,
pub used_delta: bool,
pub approvals: Option<ReleaseApprovals>,
pub source: Option<VerifiedSource>,
}
impl std::fmt::Debug for VerifiedDownload {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("VerifiedDownload")
.field("container_len", &self.container.len())
.field("manifest", &self.manifest)
.field("signature", &self.signature)
.field("used_delta", &self.used_delta)
.field("approvals", &self.approvals)
.field("source", &self.source)
.finish()
}
}
#[derive(Debug, Default)]
pub struct FileTransport;
impl Transport for FileTransport {
fn fetch(&mut self, url: &str) -> Result<Vec<u8>, CodePushError> {
let path = decode_path(url.strip_prefix("file://").unwrap_or(url))?;
std::fs::read(&path).map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
CodePushError::NotFound(path)
} else {
CodePushError::Transport(format!("could not read {path}: {error}"))
}
})
}
}
fn decode_path(path: &str) -> Result<String, CodePushError> {
let malformed = || CodePushError::Transport(format!("malformed path {path:?}"));
let mut decoded = Vec::with_capacity(path.len());
let mut bytes = path.bytes();
while let Some(byte) = bytes.next() {
if byte != b'%' {
decoded.push(byte);
continue;
}
let high = bytes
.next()
.and_then(|digit| char::from(digit).to_digit(16));
let low = bytes
.next()
.and_then(|digit| char::from(digit).to_digit(16));
let (Some(high), Some(low)) = (high, low) else {
return Err(malformed());
};
decoded.push((high * 16 + low) as u8);
}
String::from_utf8(decoded).map_err(|_| malformed())
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum UpdatePlan {
UpToDate,
Available(Box<UpdateOffer>),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct UpdateOffer {
pub manifest: BundleManifest,
#[serde(default)]
pub signature: Option<ReleaseSignature>,
pub download_url: String,
pub delta_url: Option<String>,
pub delta_base_hash: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SyncReport {
pub updated: bool,
pub version: Option<Version>,
pub used_delta: bool,
}
pub struct CodePushClient<T: Transport> {
transport: T,
base_url: String,
app: String,
channel: String,
current_version: Version,
current_build: u64,
current_container: Vec<u8>,
trusted_keys: TrustedKeys,
max_container_bytes: u64,
provenance: Option<ProvenancePolicy>,
}
impl<T: Transport> CodePushClient<T> {
#[expect(
clippy::too_many_arguments,
reason = "each argument is required, independent client state"
)]
pub fn new(
transport: T,
base_url: impl Into<String>,
app: impl Into<String>,
channel: impl Into<String>,
current_version: Version,
current_build: u64,
current_container: Vec<u8>,
trusted_keys: TrustedKeys,
) -> Self {
Self {
transport,
base_url: base_url.into().trim_end_matches('/').to_owned(),
app: app.into(),
channel: channel.into(),
current_version,
current_build,
current_container,
trusted_keys,
max_container_bytes: crate::manifest::MAX_CONTAINER_BYTES,
provenance: None,
}
}
#[must_use]
pub fn with_provenance(mut self, policy: ProvenancePolicy) -> Self {
self.provenance = Some(policy);
self
}
#[must_use]
pub fn with_max_container_bytes(mut self, limit: u64) -> Self {
self.max_container_bytes = limit;
self
}
pub fn check(&mut self) -> Result<UpdatePlan, CodePushError> {
let index_url = self.url(&ObjectKey::index(&self.app, &self.channel));
let body = match self
.transport
.fetch_within(&index_url, ObjectKind::Index.max_bytes())
{
Ok(body) => body,
Err(CodePushError::NotFound(_)) => return Ok(UpdatePlan::UpToDate),
Err(error) => return Err(error),
};
if body.len() as u64 > ObjectKind::Index.max_bytes() {
return Err(CodePushError::Malformed(format!(
"channel index exceeds {} bytes",
ObjectKind::Index.max_bytes()
)));
}
let index: ChannelIndex = serde_json::from_slice(&body)?;
if index.format_version != INDEX_FORMAT_VERSION {
return Err(CodePushError::UnsupportedFormatVersion(
index.format_version,
));
}
self.verify_release(&index.manifest, index.signature.as_ref())?;
if !self.is_newer(&index.manifest) {
return Ok(UpdatePlan::UpToDate);
}
let manifest = index.manifest;
let (app, channel) = (manifest.app.as_str(), manifest.channel.as_str());
let (version, build) = (&manifest.version, manifest.build);
let release = ObjectKey::release(app, channel, version, build);
let delta = (!self.current_container.is_empty())
.then(|| hash_bytes(&self.current_container))
.and_then(|base_hash| {
let key = ObjectKey::delta(app, channel, version, build, &base_hash)?;
Some((key, base_hash))
});
Ok(UpdatePlan::Available(Box::new(UpdateOffer {
download_url: format!("/{}", release.url_path()),
delta_url: delta
.as_ref()
.map(|(key, _)| format!("/{}", key.url_path())),
delta_base_hash: delta.map(|(_, base_hash)| base_hash),
manifest,
signature: index.signature,
})))
}
pub fn verify_offer(&self, offer: &UpdateOffer) -> Result<(), CodePushError> {
let manifest = &offer.manifest;
self.verify_release(manifest, offer.signature.as_ref())?;
if !self.is_newer(manifest) {
return Err(CodePushError::NotNewer {
current: format!("{}+{}", self.current_version, self.current_build),
offered: format!("{}+{}", manifest.version, manifest.build),
});
}
Ok(())
}
fn verify_release(
&self,
manifest: &BundleManifest,
signature: Option<&ReleaseSignature>,
) -> Result<(), CodePushError> {
self.trusted_keys.verify(manifest, signature)?;
if manifest.app != self.app || manifest.channel != self.channel {
return Err(CodePushError::WrongTarget {
tracked: format!("{}/{}", self.app, self.channel),
offered: format!("{}/{}", manifest.app, manifest.channel),
});
}
Ok(())
}
fn is_newer(&self, manifest: &BundleManifest) -> bool {
(&manifest.version, manifest.build) > (&self.current_version, self.current_build)
}
pub fn sync(&mut self, staging_dir: &Path) -> Result<SyncReport, CodePushError> {
let Some(download) = self.download_verified()? else {
return Ok(SyncReport {
updated: false,
version: None,
used_delta: false,
});
};
let parsed = read_container(&download.container)?;
stage(staging_dir, &parsed)?;
self.current_container = download.container;
self.current_version = download.manifest.version.clone();
self.current_build = download.manifest.build;
Ok(SyncReport {
updated: true,
version: Some(download.manifest.version),
used_delta: download.used_delta,
})
}
pub fn download_verified(&mut self) -> Result<Option<VerifiedDownload>, CodePushError> {
match self.check()? {
UpdatePlan::UpToDate => Ok(None),
UpdatePlan::Available(offer) => self.download_offer(*offer).map(Some),
}
}
pub fn fetch_approvals(
&mut self,
manifest: &BundleManifest,
) -> Result<ReleaseApprovals, CodePushError> {
let url = self.url(&ObjectKey::approvals_of(manifest));
match self.transport.fetch(&url) {
Ok(body) => ReleaseApprovals::from_slice(&body),
Err(CodePushError::NotFound(_)) => Ok(ReleaseApprovals::empty()),
Err(error) => Err(error),
}
}
pub(crate) fn download_offer(
&mut self,
offer: UpdateOffer,
) -> Result<VerifiedDownload, CodePushError> {
let (approvals, source) = if self.provenance.is_some() {
let approvals = self.fetch_approvals(&offer.manifest)?;
let policy = self.provenance.as_ref().expect("checked above");
let source = policy.verify(&offer.manifest, &approvals.approvals)?;
(Some(approvals), Some(source))
} else {
(None, None)
};
let delta_bytes = match (offer.delta_url.as_ref(), offer.delta_base_hash.as_ref()) {
(Some(delta_url), Some(base_hash)) => self.fetch_delta(delta_url, base_hash).ok(),
_ => None,
};
let used_delta = delta_bytes.is_some();
let container_bytes = if let Some(bytes) = delta_bytes {
bytes
} else {
let url = self.resolve(&offer.download_url);
let bytes = self
.transport
.fetch_within(&url, self.max_container_bytes)?;
self.within_limit("container", bytes.len())?;
bytes
};
let (manifest, _) = crate::manifest::container_manifest(&container_bytes)?;
if manifest.bundle_hash() != offer.manifest.bundle_hash() {
return Err(CodePushError::HashMismatch {
path: "<container>".to_owned(),
expected: offer.manifest.bundle_hash(),
actual: manifest.bundle_hash(),
});
}
let parsed = read_container(&container_bytes)?;
let signature = offer
.signature
.ok_or_else(|| CodePushError::Unsigned(parsed.manifest.bundle_hash()))?;
Ok(VerifiedDownload {
container: container_bytes,
manifest: parsed.manifest,
signature,
used_delta,
approvals,
source,
})
}
fn within_limit(&self, what: &str, len: usize) -> Result<(), CodePushError> {
if len as u64 > self.max_container_bytes {
return Err(CodePushError::Malformed(format!(
"the {what} is {len} bytes, over the {} byte limit",
self.max_container_bytes
)));
}
Ok(())
}
fn fetch_delta(&mut self, delta_url: &str, base_hash: &str) -> Result<Vec<u8>, CodePushError> {
let url = self.resolve(delta_url);
let delta_bytes = self
.transport
.fetch_within(&url, self.max_container_bytes)?;
self.within_limit("delta", delta_bytes.len())?;
let delta: crate::delta::Delta = serde_json::from_slice(&delta_bytes)?;
let current_hash = hash_bytes(&self.current_container);
if delta.base_hash != base_hash || delta.base_hash != current_hash {
return Err(CodePushError::DeltaBaseMismatch {
expected: base_hash.to_owned(),
actual: current_hash,
});
}
crate::delta::apply_delta_within(&self.current_container, &delta, self.max_container_bytes)
}
fn resolve(&self, url: &str) -> String {
format!("{}/{}", self.base_url, url.trim_start_matches('/'))
}
fn url(&self, key: &ObjectKey) -> String {
format!("{}/{}", self.base_url, key.url_path())
}
pub fn current_version(&self) -> &Version {
&self.current_version
}
}
fn stage(staging_dir: &Path, container: &crate::manifest::Container) -> Result<(), CodePushError> {
let parent = durable::parent_of(staging_dir);
std::fs::create_dir_all(parent)?;
let files: Vec<(&str, &[u8])> = container
.files
.iter()
.map(|(path, content)| (path.as_str(), content.as_slice()))
.collect();
let staged = durable::assemble_directory(staging_dir, &files)?;
let previous = if staging_dir.try_exists()? {
let previous = durable::temporary_sibling(staging_dir)?;
if let Err(error) = std::fs::rename(staging_dir, &previous) {
let _ = std::fs::remove_dir_all(&staged);
return Err(error.into());
}
Some(previous)
} else {
None
};
if let Err(error) = std::fs::rename(&staged, staging_dir) {
if let Some(previous) = &previous {
let _ = std::fs::rename(previous, staging_dir);
}
let _ = std::fs::remove_dir_all(&staged);
return Err(error.into());
}
durable::sync_directory(parent)?;
if let Some(previous) = previous {
std::fs::remove_dir_all(previous)?;
}
Ok(())
}
impl ReleaseStore {
pub fn plan_update(
&self,
app: &str,
channel: &str,
current: &Version,
current_build: u64,
) -> Result<UpdatePlan, crate::store::StoreError> {
let Some(index) = self.index(app, channel)? else {
return Ok(UpdatePlan::UpToDate);
};
let latest = index.manifest;
let newer = latest.version > *current
|| (latest.version == *current && latest.build > current_build);
if !newer {
return Ok(UpdatePlan::UpToDate);
}
let current_hash = self.container_hash(app, channel, current, current_build)?;
let delta_key = current_hash
.as_ref()
.and_then(|hash| ObjectKey::delta(app, channel, &latest.version, latest.build, hash));
let has_delta = match delta_key {
Some(key) => self.object_exists(&key)?,
None => false,
};
let (app, channel) = (encode_component(app), encode_component(channel));
Ok(UpdatePlan::Available(Box::new(UpdateOffer {
download_url: format!(
"/{app}/{channel}/download/{}/{}",
encode_component(&latest.version.to_string()),
latest.build
),
delta_url: has_delta.then(|| {
format!(
"/{app}/{channel}/delta/{}/{current_build}",
encode_component(¤t.to_string())
)
}),
delta_base_hash: current_hash.filter(|_| has_delta),
manifest: latest,
signature: index.signature,
})))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::Container;
use crate::manifest::MANIFEST_FORMAT_VERSION;
fn container(files: &[(&str, &[u8])]) -> Container {
Container {
manifest: BundleManifest {
format_version: MANIFEST_FORMAT_VERSION,
app: "app".to_owned(),
channel: "production".to_owned(),
version: Version::new(1, 0, 0),
build: 1,
entry: "app.wasm".to_owned(),
files: Vec::new(),
created_unix: 0,
mandatory: false,
},
files: files
.iter()
.map(|(path, content)| ((*path).to_owned(), content.to_vec()))
.collect(),
}
}
fn tree(directory: &Path) -> Vec<(String, Vec<u8>)> {
walkdir::WalkDir::new(directory)
.sort_by_file_name()
.into_iter()
.map(|entry| entry.expect("walk"))
.filter(|entry| entry.file_type().is_file())
.map(|entry| {
let relative = entry
.path()
.strip_prefix(directory)
.expect("under the directory")
.to_string_lossy()
.replace('\\', "/");
(relative, std::fs::read(entry.path()).expect("read"))
})
.collect()
}
fn names(directory: &Path) -> Vec<String> {
let mut names: Vec<_> = std::fs::read_dir(directory)
.expect("read directory")
.map(|entry| {
entry
.expect("entry")
.file_name()
.to_string_lossy()
.into_owned()
})
.collect();
names.sort();
names
}
#[test]
fn staging_replaces_the_previous_release_whole() {
let parent = tempfile::tempdir().expect("parent");
let staging = parent.path().join("staged");
stage(
&staging,
&container(&[("app.wasm", b"v1"), ("assets/old.txt", b"not in v2")]),
)
.expect("stage v1");
stage(&staging, &container(&[("app.wasm", b"v2")])).expect("stage v2");
assert_eq!(tree(&staging), [("app.wasm".to_owned(), b"v2".to_vec())]);
assert_eq!(
names(parent.path()),
["staged"],
"nothing temporary survives"
);
}
#[test]
fn a_failed_stage_leaves_the_previous_staging_intact() {
let parent = tempfile::tempdir().expect("parent");
let staging = parent.path().join("staged");
let v1 = [("app.wasm", b"v1".as_slice()), ("assets/logo.png", b"logo")];
stage(&staging, &container(&v1)).expect("stage v1");
let broken = container(&[
("app.wasm", b"v2"),
("assets", b"a file"),
("assets/logo.png", b"new"),
]);
assert!(stage(&staging, &broken).is_err());
assert_eq!(
tree(&staging),
v1.map(|(path, content)| (path.to_owned(), content.to_vec()))
);
assert_eq!(
names(parent.path()),
["staged"],
"nothing temporary survives"
);
}
}