mod ownership;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use semver::Version;
use serde::Deserialize;
use serde::Serialize;
use crate::BundleManifest;
use crate::CodePushClient;
use crate::CodePushError;
use crate::ReleaseSignature;
use crate::Transport;
use crate::TrustedKeys;
use crate::UpdatePlan;
use crate::durable::is_temporary;
use crate::durable::sync_directory;
use crate::durable::write_atomically;
use crate::manifest::Container;
use crate::provenance::MAX_APPROVALS_BYTES;
use crate::provenance::ProvenanceConfiguration;
use crate::provenance::ProvenancePolicy;
use crate::provenance::ReleaseApprovals;
use crate::read_container;
pub const MAX_BUNDLE_BYTES: u64 = crate::manifest::MAX_CONTAINER_BYTES;
const APPROVALS_FILE: &str = "approvals.json";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RuntimeContract {
pub abi: u32,
pub shell: String,
pub plugins: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UpdateConfiguration {
pub enabled: bool,
pub app: String,
pub channel: String,
pub server: String,
pub public_keys: Vec<String>,
pub runtime: RuntimeContract,
pub allow_http: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance: Option<ProvenanceConfiguration>,
}
#[derive(Debug)]
pub struct LaunchBundle {
pub wasm: Vec<u8>,
pub metadata: LaunchMetadata,
}
#[derive(Debug, Clone, Serialize)]
pub struct LaunchMetadata {
pub receipt: String,
pub version: Version,
pub build: u64,
pub candidate: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct UpdateReport {
pub status: UpdateStatus,
pub version: Version,
pub build: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum UpdateStatus {
Current,
Staged,
}
const MAX_REJECTED: usize = 64;
const FLOOR_FILE: &str = "floor.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct State {
format: u32,
embedded: String,
confirmed: Option<String>,
candidate: Option<Candidate>,
generation: u64,
rejected: Vec<String>,
launch: Option<Receipt>,
#[serde(default)]
floor: Option<Floor>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Floor {
format: u32,
embedded: String,
version: Version,
build: u64,
}
impl Floor {
fn of(manifest: &BundleManifest, embedded: &str) -> Self {
Self {
format: 1,
embedded: embedded.to_owned(),
version: manifest.version.clone(),
build: manifest.build,
}
}
fn order(&self) -> (&Version, u64) {
(&self.version, self.build)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case", deny_unknown_fields)]
enum Candidate {
Ready { release: String },
Trying { release: String },
}
impl Candidate {
fn release(&self) -> &str {
match self {
Self::Ready { release } | Self::Trying { release } => release,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Receipt {
id: String,
release: Option<String>,
resolution: Resolution,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
enum Resolution {
Pending,
Confirmed,
Rejected,
}
pub struct InstalledBundles {
directory: PathBuf,
embedded: Vec<u8>,
config: UpdateConfiguration,
keys: Option<TrustedKeys>,
provenance: Option<ProvenancePolicy>,
state: State,
floor: Floor,
recovery: Option<String>,
just_confirmed: Option<(String, Option<String>)>,
_directory_lock: fs::File,
}
impl InstalledBundles {
pub fn open(
directory: &Path,
embedded: &[u8],
config: UpdateConfiguration,
) -> Result<Self, CodePushError> {
let keys = if config.enabled || !config.public_keys.is_empty() {
Some(TrustedKeys::from_hex(&config.public_keys)?)
} else {
None
};
let provenance = provenance_policy(&config, keys.as_ref())?;
let baseline = validate_container(embedded, &config)?;
let baseline_hash = baseline.manifest.bundle_hash();
let directory_lock = ownership::acquire(directory)?;
fs::create_dir_all(directory.join("releases"))?;
let initial = || {
State {
format: 1,
embedded: baseline_hash.clone(),
confirmed: None,
candidate: None,
generation: 0,
rejected: Vec::new(),
launch: None,
floor: None,
}
};
let mut recovery = None;
let state = match fs::read(directory.join("state.json")) {
Ok(bytes) => {
match serde_json::from_slice::<State>(&bytes) {
Ok(state) if state.format == 1 => state,
parsed => {
recovery =
Some(format!("discarded invalid installed selector: {parsed:?}"));
initial()
}
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => initial(),
Err(error) => return Err(error.into()),
};
let stored_floor = match fs::read(directory.join(FLOOR_FILE)) {
Ok(bytes) => {
match serde_json::from_slice::<Floor>(&bytes) {
Ok(floor) if floor.format == 1 => Some(floor),
parsed => {
recovery = Some(format!("discarded invalid version floor: {parsed:?}"));
None
}
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => return Err(error.into()),
};
let recorded: Vec<Floor> = stored_floor
.iter()
.chain(&state.floor)
.filter(|floor| floor.embedded == baseline_hash)
.cloned()
.collect();
let mut installed = Self {
directory: directory.to_owned(),
embedded: embedded.to_vec(),
config,
keys,
provenance,
state,
floor: Floor::of(&baseline.manifest, &baseline_hash),
recovery,
just_confirmed: None,
_directory_lock: directory_lock,
};
if installed.state.embedded != baseline_hash {
installed.state.confirmed = None;
installed.state.candidate = None;
installed.state.launch = None;
installed.state.rejected.clear();
}
installed.recover(&baseline.manifest, recorded);
installed.state.embedded = baseline_hash;
if stored_floor.as_ref() != Some(&installed.floor) {
installed.write_floor()?;
}
installed.persist()?;
installed.prune()?;
Ok(installed)
}
pub fn recovery_diagnostic(&self) -> Option<&str> {
self.recovery.as_deref()
}
pub fn launch(&mut self) -> Result<LaunchBundle, CodePushError> {
self.just_confirmed = None;
if let Some(Candidate::Trying { release }) = self.state.candidate.clone() {
self.recovery = Some("unconfirmed trial recovered to working release".to_owned());
self.reject_release(release);
}
let (release, parsed) = loop {
let release = self
.state
.candidate
.as_ref()
.map(|candidate| candidate.release().to_owned())
.or_else(|| self.state.confirmed.clone());
match (self.load(release.as_deref()), release) {
(Err(error), Some(damaged)) => self.forget_damaged(&damaged, &error),
(loaded, release) => break (release, loaded?.1),
}
};
let candidate = self.state.candidate.is_some();
let wasm = entry_of(&parsed)?.to_vec();
self.state.generation = self
.state
.generation
.checked_add(1)
.ok_or_else(|| activation("launch generation exhausted"))?;
let receipt = format!(
"{}:{}:{}",
self.state.embedded,
self.state.generation,
parsed.manifest.bundle_hash()
);
self.state.launch = Some(Receipt {
id: receipt.clone(),
release: release.clone(),
resolution: Resolution::Pending,
});
if candidate {
self.state.candidate = release.map(|release| Candidate::Trying { release });
}
self.persist()?;
Ok(LaunchBundle {
wasm,
metadata: LaunchMetadata {
receipt,
version: parsed.manifest.version,
build: parsed.manifest.build,
candidate,
},
})
}
pub fn confirm(&mut self, receipt: &str) -> Result<(), CodePushError> {
let launch = self.receipt(receipt, Resolution::Confirmed)?;
if launch.resolution == Resolution::Confirmed {
return Ok(());
}
let (_, parsed) = self.load(launch.release.as_deref())?;
let release = launch.release.clone();
let confirms_trial = self.state.candidate.as_ref().is_some_and(|candidate| {
matches!(candidate, Candidate::Trying { .. })
&& release.as_deref() == Some(candidate.release())
});
if confirms_trial {
self.raise_floor(&parsed.manifest)?;
self.just_confirmed = Some((receipt.to_owned(), self.state.confirmed.clone()));
self.state.confirmed = release;
self.state.candidate = None;
}
self.state
.launch
.as_mut()
.expect("receipt checked")
.resolution = Resolution::Confirmed;
self.persist()
}
pub fn reject(&mut self, receipt: &str) -> Result<(), CodePushError> {
if self
.just_confirmed
.as_ref()
.is_some_and(|(id, _)| id == receipt)
{
let launch = self.receipt(receipt, Resolution::Confirmed)?;
let release = launch
.release
.clone()
.ok_or_else(|| activation("confirmed trial has no release"))?;
let (_, previous) = self
.just_confirmed
.take()
.expect("matching live confirmation");
self.state.confirmed = previous;
self.reject_release(release);
self.state
.launch
.as_mut()
.expect("receipt checked")
.resolution = Resolution::Rejected;
return self.persist();
}
let launch = self.receipt(receipt, Resolution::Rejected)?;
if launch.resolution == Resolution::Rejected {
return Ok(());
}
let release = launch.release.clone();
let is_trial = self
.state
.candidate
.as_ref()
.is_some_and(|candidate| release.as_deref() == Some(candidate.release()));
if !is_trial {
return Err(activation(
"the working baseline failed; no earlier trial to recover",
));
}
if let Some(release) = release {
self.reject_release(release);
}
self.state
.launch
.as_mut()
.expect("receipt checked")
.resolution = Resolution::Rejected;
self.persist()
}
#[cfg(all(feature = "http", not(target_arch = "wasm32")))]
pub fn sync(&mut self) -> Result<UpdateReport, CodePushError> {
let mut transport = HttpTransport::new(self.config.allow_http);
self.sync_with(&mut transport)
}
pub fn sync_with(
&mut self,
transport: &mut impl Transport,
) -> Result<UpdateReport, CodePushError> {
self.just_confirmed = None;
if let Some(Candidate::Ready { release }) = &self.state.candidate {
let (_, parsed) = self.load(Some(release))?;
return Ok(report(UpdateStatus::Staged, parsed.manifest));
}
let (current_bytes, current) = self.load(self.state.confirmed.as_deref())?;
if !self.config.enabled {
return Ok(report(UpdateStatus::Current, current.manifest));
}
if matches!(self.state.candidate, Some(Candidate::Trying { .. })) {
return Err(activation(
"confirm or reject the outstanding trial before checking",
));
}
self.prune()?;
let keys = self
.keys
.clone()
.ok_or_else(|| activation("no update trust keys"))?;
let mut client = CodePushClient::new(
transport,
&self.config.server,
&self.config.app,
&self.config.channel,
current.manifest.version.clone(),
current.manifest.build,
current_bytes,
keys,
)
.with_max_container_bytes(MAX_BUNDLE_BYTES);
if let Some(policy) = &self.provenance {
client = client.with_provenance(policy.clone());
}
let UpdatePlan::Available(offer) = client.check()? else {
return Ok(report(UpdateStatus::Current, current.manifest));
};
if release_order(&offer.manifest) <= self.floor.order() {
return Ok(report(UpdateStatus::Current, current.manifest));
}
if self.state.rejected.contains(&offer.manifest.bundle_hash()) {
return Ok(report(UpdateStatus::Current, current.manifest));
}
let offered = offer.manifest.bundle_hash();
let download = client.download_offer(*offer)?;
let parsed = match validate_container(&download.container, &self.config) {
Ok(parsed) => parsed,
Err(error) => {
self.reject_release(offered);
self.persist()?;
return Err(error);
}
};
let id = parsed.manifest.bundle_hash();
let release_dir = self.release_directory(&id)?;
fs::create_dir_all(&release_dir)?;
write_atomically(&release_dir.join("release.lkb"), &download.container)?;
write_atomically(
&release_dir.join("release.sig"),
&serde_json::to_vec(&download.signature)?,
)?;
if let Some(approvals) = &download.approvals {
write_atomically(&release_dir.join(APPROVALS_FILE), &approvals.to_vec()?)?;
}
sync_directory(&self.directory.join("releases"))?;
self.state.candidate = Some(Candidate::Ready { release: id });
self.persist()?;
Ok(report(UpdateStatus::Staged, parsed.manifest))
}
fn receipt(&self, id: &str, resolution: Resolution) -> Result<&Receipt, CodePushError> {
let receipt = self
.state
.launch
.as_ref()
.filter(|launch| launch.id == id)
.ok_or_else(|| activation("stale launch receipt"))?;
if receipt.resolution != Resolution::Pending && receipt.resolution != resolution {
return Err(activation("launch receipt already resolved differently"));
}
Ok(receipt)
}
fn reject_release(&mut self, release: String) {
if !self.state.rejected.contains(&release) {
if self.state.rejected.len() >= MAX_REJECTED {
self.state.rejected.remove(0);
}
self.state.rejected.push(release);
}
self.state.candidate = None;
}
fn forget_damaged(&mut self, release: &str, error: &CodePushError) {
self.recovery = Some(format!("installed release rejected: {error}"));
let is_candidate = self
.state
.candidate
.as_ref()
.is_some_and(|candidate| candidate.release() == release);
if is_candidate {
self.reject_release(release.to_owned());
} else {
self.state.confirmed = None;
}
}
fn recover(&mut self, baseline: &BundleManifest, recorded: Vec<Floor>) {
if let Some(Candidate::Trying { release }) = self.state.candidate.clone() {
self.recovery = Some("unconfirmed trial recovered to working release".to_owned());
self.reject_release(release);
self.state.launch = None;
}
let mut working = baseline.clone();
if let Some(id) = self.state.confirmed.clone() {
match self.load(Some(&id)) {
Ok((_, parsed)) if release_order(&parsed.manifest) > release_order(baseline) => {
working = parsed.manifest;
}
Ok(_) => {
self.state.confirmed = None;
self.state.launch = None;
}
Err(error) => {
self.recovery = Some(format!("installed release rejected: {error}"));
self.state.confirmed = None;
self.state.launch = None;
}
}
}
self.floor = Floor::of(&working, &self.floor.embedded);
for floor in recorded {
if floor.order() > self.floor.order() {
self.floor = floor;
}
}
self.state.floor = Some(self.floor.clone());
if let Some(candidate) = self.state.candidate.clone() {
match self.load(Some(candidate.release())) {
Ok((_, parsed)) if release_order(&parsed.manifest) > self.floor.order() => {}
Ok(_) => self.state.candidate = None,
Err(error) => {
self.recovery = Some(format!("staged release rejected: {error}"));
self.reject_release(candidate.release().to_owned());
}
}
}
}
fn release_directory(&self, id: &str) -> Result<PathBuf, CodePushError> {
if id.len() != 64 || !id.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err(activation("invalid installed release identifier"));
}
Ok(self.directory.join("releases").join(id))
}
fn load(&self, id: Option<&str>) -> Result<(Vec<u8>, Container), CodePushError> {
let Some(id) = id else {
return Ok((
self.embedded.clone(),
validate_container(&self.embedded, &self.config)?,
));
};
let directory = self.release_directory(id)?;
let bytes = fs::read(directory.join("release.lkb"))?;
let parsed = validate_container(&bytes, &self.config)?;
let signature: ReleaseSignature =
serde_json::from_slice(&fs::read(directory.join("release.sig"))?)?;
self.keys
.as_ref()
.ok_or_else(|| activation("installed release has no trusted key"))?
.verify(&parsed.manifest, Some(&signature))?;
if parsed.manifest.bundle_hash() != id {
return Err(activation(
"installed release identifier does not match signed container",
));
}
if let Some(policy) = &self.provenance {
let approvals = ReleaseApprovals::from_slice(&read_bounded(
&directory.join(APPROVALS_FILE),
MAX_APPROVALS_BYTES,
)?)?;
policy.verify(&parsed.manifest, &approvals.approvals)?;
}
Ok((bytes, parsed))
}
fn prune(&self) -> Result<(), CodePushError> {
for entry in fs::read_dir(&self.directory)? {
let entry = entry?;
let interrupted = entry.file_name().to_str().is_some_and(is_temporary);
if interrupted && entry.file_type()?.is_file() {
fs::remove_file(entry.path())?;
}
}
let keep = [
self.state.confirmed.as_deref(),
self.state.candidate.as_ref().map(Candidate::release),
];
for entry in fs::read_dir(self.directory.join("releases"))? {
let entry = entry?;
if keep.contains(&entry.file_name().to_str()) {
continue;
}
if entry.file_type()?.is_dir() {
fs::remove_dir_all(entry.path())?;
} else {
fs::remove_file(entry.path())?;
}
}
Ok(())
}
fn raise_floor(&mut self, manifest: &BundleManifest) -> Result<(), CodePushError> {
if release_order(manifest) <= self.floor.order() {
return Ok(());
}
let raised = Floor::of(manifest, &self.floor.embedded);
let previous = std::mem::replace(&mut self.floor, raised);
if let Err(error) = self.write_floor() {
self.floor = previous;
return Err(error);
}
self.state.floor = Some(self.floor.clone());
Ok(())
}
fn write_floor(&self) -> Result<(), CodePushError> {
write_atomically(
&self.directory.join(FLOOR_FILE),
&serde_json::to_vec(&self.floor)?,
)?;
Ok(())
}
fn persist(&self) -> Result<(), CodePushError> {
write_atomically(
&self.directory.join("state.json"),
&serde_json::to_vec(&self.state)?,
)?;
Ok(())
}
}
fn read_bounded(path: &Path, limit: u64) -> Result<Vec<u8>, CodePushError> {
use std::io::Read;
let mut bytes = Vec::new();
fs::File::open(path)?
.take(limit.saturating_add(1))
.read_to_end(&mut bytes)?;
Ok(bytes)
}
fn provenance_policy(
config: &UpdateConfiguration,
publishers: Option<&TrustedKeys>,
) -> Result<Option<ProvenancePolicy>, CodePushError> {
let Some(configuration) = &config.provenance else {
return Ok(None);
};
let policy = ProvenancePolicy::from_configuration(configuration)?;
let shared = publishers.and_then(|publishers| {
policy
.approvers()
.iter()
.find(|key| publishers.contains(key))
});
if let Some(shared) = shared {
return Err(CodePushError::InvalidProvenance(format!(
"{} is both a publisher key and an approver key",
shared.to_hex()
)));
}
Ok(Some(policy))
}
fn release_order(manifest: &BundleManifest) -> (&Version, u64) {
(&manifest.version, manifest.build)
}
fn report(status: UpdateStatus, manifest: BundleManifest) -> UpdateReport {
UpdateReport {
status,
version: manifest.version,
build: manifest.build,
}
}
fn activation(message: impl Into<String>) -> CodePushError {
CodePushError::Activation(message.into())
}
pub fn entry_module(container: &[u8]) -> Result<Vec<u8>, CodePushError> {
let parsed = parse_bounded(container)?;
Ok(entry_of(&parsed)?.to_vec())
}
fn parse_bounded(bytes: &[u8]) -> Result<Container, CodePushError> {
if bytes.len() as u64 > MAX_BUNDLE_BYTES {
return Err(activation("container exceeds 32 MiB"));
}
let (manifest, _) = crate::manifest::container_manifest(bytes)?;
let total = manifest
.files
.iter()
.try_fold(0_u64, |sum, file| sum.checked_add(file.size));
if total.is_none_or(|size| size > MAX_BUNDLE_BYTES) {
return Err(activation("expanded bundle exceeds 32 MiB"));
}
read_container(bytes)
}
fn entry_of(parsed: &Container) -> Result<&[u8], CodePushError> {
let entry = parsed
.files
.iter()
.find(|(path, _)| path == &parsed.manifest.entry)
.ok_or_else(|| activation("bundle entry is missing"))?;
if !entry.1.starts_with(b"\0asm\x01\0\0\0") {
return Err(activation(
"bundle entry is not a WebAssembly version 1 module",
));
}
Ok(&entry.1)
}
fn validate_container(
bytes: &[u8],
config: &UpdateConfiguration,
) -> Result<Container, CodePushError> {
let parsed = parse_bounded(bytes)?;
if parsed.manifest.app != config.app || parsed.manifest.channel != config.channel {
return Err(CodePushError::WrongTarget {
tracked: format!("{}/{}", config.app, config.channel),
offered: format!("{}/{}", parsed.manifest.app, parsed.manifest.channel),
});
}
let descriptor = parsed
.files
.iter()
.find(|(path, _)| path == "runtime.json")
.ok_or_else(|| activation("native bundle is missing runtime.json"))?;
let runtime: RuntimeContract = serde_json::from_slice(&descriptor.1)?;
if runtime.abi != config.runtime.abi
|| runtime.shell != config.runtime.shell
|| runtime
.plugins
.iter()
.any(|plugin| !config.runtime.plugins.contains(plugin))
{
return Err(activation(
"bundle requires an incompatible native ABI, shell, or plugin",
));
}
entry_of(&parsed)?;
Ok(parsed)
}
#[cfg(all(feature = "http", not(target_arch = "wasm32")))]
pub struct HttpTransport {
agent: ureq::Agent,
allow_http: bool,
}
#[cfg(all(feature = "http", not(target_arch = "wasm32")))]
impl HttpTransport {
pub fn new(allow_http: bool) -> Self {
let config = ureq::Agent::config_builder()
.max_redirects(0)
.http_status_as_error(false)
.timeout_connect(Some(std::time::Duration::from_secs(15)))
.timeout_recv_response(Some(std::time::Duration::from_secs(30)))
.timeout_global(Some(std::time::Duration::from_mins(10)))
.build();
Self {
agent: config.into(),
allow_http,
}
}
}
#[cfg(all(feature = "http", not(target_arch = "wasm32")))]
impl Transport for HttpTransport {
fn fetch(&mut self, url: &str) -> Result<Vec<u8>, CodePushError> {
self.fetch_within(url, MAX_BUNDLE_BYTES)
}
fn fetch_within(&mut self, url: &str, max_bytes: u64) -> Result<Vec<u8>, CodePushError> {
let secure = url.starts_with("https://");
let development = self.allow_http && url.starts_with("http://");
if !(secure || development) {
return Err(CodePushError::Transport(
"HTTPS is required; enable allow_http only for development".to_owned(),
));
}
let mut response = self
.agent
.get(url)
.call()
.map_err(|error| CodePushError::Transport(error.to_string()))?;
let status = response.status();
if status == ureq::http::StatusCode::NOT_FOUND {
return Err(CodePushError::NotFound(url.to_owned()));
}
if !status.is_success() {
return Err(CodePushError::Transport(format!("HTTP {status}")));
}
response
.body_mut()
.with_config()
.limit(max_bytes.min(MAX_BUNDLE_BYTES))
.read_to_vec()
.map_err(|error| CodePushError::Transport(error.to_string()))
}
}