use std::fs;
use std::io::ErrorKind;
use std::io::Read;
use std::path::Path;
use std::path::PathBuf;
use semver::Version;
use crate::CodePushError;
use crate::delta::Delta;
use crate::delta::create_delta;
use crate::durable;
use crate::durable::write_atomically;
use crate::layout::ChannelIndex;
use crate::layout::DELTAS_DIR;
use crate::layout::INDEX_FORMAT_VERSION;
use crate::layout::ObjectKey;
use crate::layout::RELEASE_FILE;
use crate::layout::SIGNATURE_FILE;
use crate::layout::StaticObject;
use crate::layout::channel_path;
use crate::layout::delta_file_name;
use crate::layout::parse_delta_file_name;
use crate::layout::parse_release_name;
use crate::layout::release_path;
use crate::manifest::BundleManifest;
use crate::manifest::bounded_container_manifest;
use crate::manifest::container_bytes;
use crate::manifest::hash_bytes;
use crate::manifest::pack_directory;
use crate::manifest::read_container;
use crate::provenance::ReleaseApprovals;
use crate::provenance::SignedApproval;
use crate::signing::ReleaseSignature;
use crate::signing::ReleaseSigningKey;
const CONTAINER_HASH_FILE: &str = "release.sha256";
#[derive(Debug, Clone)]
pub struct ReleaseRecord {
pub manifest: BundleManifest,
pub container: Vec<u8>,
pub signature: Option<ReleaseSignature>,
}
impl ReleaseRecord {
pub fn channel_index(&self) -> ChannelIndex {
ChannelIndex {
format_version: INDEX_FORMAT_VERSION,
manifest: self.manifest.clone(),
signature: self.signature.clone(),
}
}
pub(crate) fn id(&self) -> (&Version, u64) {
(&self.manifest.version, self.manifest.build)
}
}
#[derive(Debug, Clone)]
pub struct IngestedRelease {
pub record: ReleaseRecord,
pub delta_from_previous: Option<Delta>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AttachedApprovals {
pub release: String,
pub added: usize,
pub total: usize,
}
#[derive(Debug, Clone)]
pub struct ReleaseStore {
root: PathBuf,
}
impl ReleaseStore {
pub fn open(root: impl Into<PathBuf>) -> Result<Self, CodePushError> {
let root = root.into();
fs::create_dir_all(&root)?;
Ok(Self { root })
}
pub fn root(&self) -> &Path {
&self.root
}
fn path_of(&self, key: &ObjectKey) -> PathBuf {
self.root.join(key.as_str())
}
fn release_dir(&self, app: &str, channel: &str, version: &Version, build: u64) -> PathBuf {
self.root.join(release_path(app, channel, version, build))
}
#[expect(
clippy::too_many_arguments,
reason = "each argument is an independent part of the release identity"
)]
pub fn ingest_directory(
&self,
signer: &ReleaseSigningKey,
app: &str,
channel: &str,
version: &Version,
build: u64,
dir: &Path,
mandatory: bool,
) -> Result<IngestedRelease, CodePushError> {
let release_exists =
|| CodePushError::ReleaseExists(format!("{app}/{channel}/{version}+{build}"));
let release_dir = self.release_dir(app, channel, version, build);
if release_dir.join(RELEASE_FILE).try_exists()? {
return Err(release_exists());
}
let (mut manifest, blobs) = pack_directory(dir)?;
manifest.app = app.into();
manifest.channel = channel.into();
manifest.version = version.clone();
manifest.build = build;
manifest.mandatory = mandatory;
let signature = signer.sign(&manifest);
let container = container_bytes(&manifest, &blobs)?;
bounded_container_manifest(&container)?;
let previous = self.latest(app, channel)?;
let delta = match previous {
Some(previous) if previous.manifest.bundle_hash() != manifest.bundle_hash() => {
worthwhile_delta(&previous.container, &container)?
}
_ => None,
};
let signature_json = serde_json::to_vec_pretty(&signature)?;
let container_hash = hash_bytes(&container);
let delta_path = delta
.as_ref()
.map(|(delta, _)| format!("{DELTAS_DIR}/{}", delta_file_name(&delta.base_hash)));
let mut files = vec![
(RELEASE_FILE, container.as_slice()),
(SIGNATURE_FILE, signature_json.as_slice()),
(CONTAINER_HASH_FILE, container_hash.as_bytes()),
];
if let (Some(path), Some((_, bytes))) = (&delta_path, &delta) {
files.push((path.as_str(), bytes.as_slice()));
}
if !publish_release_dir(&release_dir, &files)? {
return Err(release_exists());
}
self.refresh_index(app, channel)?;
Ok(IngestedRelease {
record: ReleaseRecord {
manifest,
container,
signature: Some(signature),
},
delta_from_previous: delta.map(|(delta, _)| delta),
})
}
pub fn import_release(
&self,
container: &[u8],
signature: &ReleaseSignature,
) -> Result<bool, CodePushError> {
let manifest = read_container(container)?.manifest;
let (app, channel) = (manifest.app.as_str(), manifest.channel.as_str());
let (version, build) = (&manifest.version, manifest.build);
let release_dir = self.release_dir(app, channel, version, build);
let signature_json = serde_json::to_vec_pretty(signature)?;
let container_hash = hash_bytes(container);
let imported = !release_dir.join(RELEASE_FILE).try_exists()?
&& publish_release_dir(
&release_dir,
&[
(RELEASE_FILE, container),
(SIGNATURE_FILE, signature_json.as_slice()),
(CONTAINER_HASH_FILE, container_hash.as_bytes()),
],
)?;
if !imported {
if fs::read(release_dir.join(RELEASE_FILE))? != container {
return Err(CodePushError::ReleaseExists(format!(
"{app}/{channel}/{version}+{build}"
)));
}
write_atomically(
&release_dir.join(CONTAINER_HASH_FILE),
container_hash.as_bytes(),
)?;
let signature_path = release_dir.join(SIGNATURE_FILE);
if !signature_path.try_exists()? {
write_atomically(&signature_path, &signature_json)?;
}
}
self.refresh_index(app, channel)?;
Ok(imported)
}
fn refresh_index(&self, app: &str, channel: &str) -> Result<(), CodePushError> {
let path = self.path_of(&ObjectKey::index(app, channel));
let mut written: Option<ChannelIndex> = None;
loop {
let Some(latest) = self.latest(app, channel)? else {
return Ok(());
};
let index = latest.channel_index();
if written.as_ref() == Some(&index) {
return Ok(());
}
write_atomically(&path, &serde_json::to_vec_pretty(&index)?)?;
written = Some(index);
}
}
pub fn index(&self, app: &str, channel: &str) -> Result<Option<ChannelIndex>, StoreError> {
read_optional_json(&self.path_of(&ObjectKey::index(app, channel)))
}
pub fn delta(
&self,
app: &str,
channel: &str,
version: &Version,
build: u64,
base_hash: &str,
) -> Result<Option<Delta>, StoreError> {
match ObjectKey::delta(app, channel, version, build, base_hash) {
Some(key) => read_optional_json(&self.path_of(&key)),
None => Ok(None),
}
}
pub fn approvals(
&self,
manifest: &BundleManifest,
) -> Result<Option<ReleaseApprovals>, StoreError> {
match read_optional(&self.path_of(&ObjectKey::approvals_of(manifest)))? {
Some(bytes) => Ok(Some(ReleaseApprovals::from_slice(&bytes)?)),
None => Ok(None),
}
}
pub fn attach_approvals(
&self,
approvals: &[SignedApproval],
) -> Result<Vec<AttachedApprovals>, CodePushError> {
let mut releases: Vec<(BundleManifest, Vec<SignedApproval>)> = Vec::new();
for approval in approvals {
let (_, statement) = approval.open()?;
let release = statement.release();
let (app, channel) = (statement.app.as_str(), statement.channel.as_str());
let manifest = self
.find(app, channel, &statement.version, statement.build)?
.ok_or_else(|| CodePushError::UnknownRelease(release.clone()))?
.manifest;
let bundle_hash = manifest.bundle_hash();
if bundle_hash != statement.bundle_hash {
return Err(CodePushError::HashMismatch {
path: release,
expected: bundle_hash,
actual: statement.bundle_hash,
});
}
match releases
.iter_mut()
.find(|(attached, _)| attached.bundle_hash() == bundle_hash)
{
Some((_, pending)) => pending.push(approval.clone()),
None => releases.push((manifest, vec![approval.clone()])),
}
}
let mut attached = Vec::with_capacity(releases.len());
for (manifest, pending) in releases {
let mut approvals = self
.approvals(&manifest)?
.unwrap_or_else(ReleaseApprovals::empty);
let added = approvals.merge(pending)?;
if added > 0 {
let path = self.path_of(&ObjectKey::approvals_of(&manifest));
fs::create_dir_all(durable::parent_of(&path))?;
write_atomically(&path, &approvals.to_vec()?)?;
}
attached.push(AttachedApprovals {
release: format!(
"{}/{}/{}+{}",
manifest.app, manifest.channel, manifest.version, manifest.build
),
added,
total: approvals.approvals.len(),
});
}
Ok(attached)
}
pub fn read_object(&self, key: &ObjectKey) -> Result<Option<Vec<u8>>, StoreError> {
read_optional(&self.path_of(key)).map_err(StoreError::from)
}
pub(crate) fn container_hash(
&self,
app: &str,
channel: &str,
version: &Version,
build: u64,
) -> Result<Option<String>, StoreError> {
let release = self.path_of(&ObjectKey::release(app, channel, version, build));
if !self.object_exists(&ObjectKey::release(app, channel, version, build))? {
return Ok(None);
}
let file = match fs::File::open(release.with_file_name(CONTAINER_HASH_FILE)) {
Ok(file) => file,
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error.into()),
};
let mut hash = String::new();
file.take(65).read_to_string(&mut hash)?;
if hash.len() != 64
|| !hash
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
return Err(
std::io::Error::new(ErrorKind::InvalidData, "invalid container hash").into(),
);
}
Ok(Some(hash))
}
pub(crate) fn object_exists(&self, key: &ObjectKey) -> Result<bool, StoreError> {
match fs::metadata(self.path_of(key)) {
Ok(metadata) if metadata.is_file() => Ok(true),
Ok(_) => {
Err(std::io::Error::new(ErrorKind::InvalidData, "expected a release file").into())
}
Err(error) if error.kind() == ErrorKind::NotFound => Ok(false),
Err(error) => Err(error.into()),
}
}
pub fn release_objects(&self, record: &ReleaseRecord) -> Result<Vec<StaticObject>, StoreError> {
let manifest = &record.manifest;
let (app, channel) = (manifest.app.as_str(), manifest.channel.as_str());
let (version, build) = (&manifest.version, manifest.build);
let mut objects = vec![StaticObject {
key: ObjectKey::release(app, channel, version, build),
bytes: record.container.clone(),
}];
let signature = ObjectKey::signature(app, channel, version, build);
if let Some(bytes) = self.read_object(&signature)? {
objects.push(StaticObject {
key: signature,
bytes,
});
}
let deltas_dir = self
.path_of(&ObjectKey::release(app, channel, version, build))
.with_file_name(DELTAS_DIR);
let entries = match fs::read_dir(&deltas_dir) {
Ok(entries) => entries,
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(objects),
Err(error) => return Err(error.into()),
};
let mut deltas = Vec::new();
for entry in entries {
let name = entry?.file_name();
let Some(key) = name
.to_str()
.and_then(parse_delta_file_name)
.and_then(|base_hash| ObjectKey::delta(app, channel, version, build, base_hash))
else {
continue;
};
if let Some(bytes) = self.read_object(&key)? {
deltas.push(StaticObject { key, bytes });
}
}
deltas.sort_by(|left, right| left.key.as_str().cmp(right.key.as_str()));
objects.extend(deltas);
Ok(objects)
}
pub fn list(&self, app: &str, channel: &str) -> Result<Vec<ReleaseRecord>, StoreError> {
let channel_dir = self.root.join(channel_path(app, channel));
let mut records = Vec::new();
let entries = match fs::read_dir(&channel_dir) {
Ok(entries) => entries,
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(records),
Err(error) => return Err(error.into()),
};
for entry in entries {
let entry = entry?;
let is_release = entry
.file_name()
.to_str()
.is_some_and(|name| parse_release_name(name).is_some());
if !is_release || !entry.file_type()?.is_dir() {
continue;
}
let container = match fs::read(entry.path().join(RELEASE_FILE)) {
Ok(container) => container,
Err(error) if error.kind() == ErrorKind::NotFound => continue,
Err(error) => return Err(error.into()),
};
let parsed = match read_container(&container) {
Ok(parsed) => parsed,
Err(CodePushError::UnsupportedFormatVersion(_)) => continue,
Err(error) => return Err(error.into()),
};
let signature = read_optional_json(&entry.path().join(SIGNATURE_FILE))?;
records.push(ReleaseRecord {
manifest: parsed.manifest,
container,
signature,
});
}
records.sort_by(|left, right| right.id().cmp(&left.id()));
Ok(records)
}
pub fn latest(&self, app: &str, channel: &str) -> Result<Option<ReleaseRecord>, StoreError> {
Ok(self.list(app, channel)?.into_iter().next())
}
pub fn find(
&self,
app: &str,
channel: &str,
version: &Version,
build: u64,
) -> Result<Option<ReleaseRecord>, StoreError> {
Ok(self
.list(app, channel)?
.into_iter()
.find(|record| record.manifest.version == *version && record.manifest.build == build))
}
}
fn read_optional(path: &Path) -> std::io::Result<Option<Vec<u8>>> {
match fs::read(path) {
Ok(bytes) => Ok(Some(bytes)),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
Err(error) => Err(error),
}
}
fn read_optional_json<T: serde::de::DeserializeOwned>(
path: &Path,
) -> Result<Option<T>, StoreError> {
match read_optional(path)? {
Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
None => Ok(None),
}
}
fn worthwhile_delta(
previous: &[u8],
container: &[u8],
) -> Result<Option<(Delta, Vec<u8>)>, CodePushError> {
let delta = create_delta(previous, container);
let bytes = serde_json::to_vec(&delta)?;
let worthwhile = bytes.len().saturating_mul(4) <= container.len().saturating_mul(3);
Ok(worthwhile.then_some((delta, bytes)))
}
fn publish_release_dir(release_dir: &Path, files: &[(&str, &[u8])]) -> Result<bool, CodePushError> {
let channel_dir = durable::parent_of(release_dir);
fs::create_dir_all(channel_dir)?;
let assembled = durable::assemble_directory(release_dir, files)?;
if let Err(error) = fs::rename(&assembled, release_dir) {
let _ = fs::remove_dir_all(&assembled);
return if release_dir.join(RELEASE_FILE).try_exists()? {
Ok(false)
} else {
Err(error.into())
};
}
durable::sync_directory(channel_dir)?;
Ok(true)
}
#[derive(Debug, thiserror::Error)]
pub enum StoreError {
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("serialization error: {0}")]
Serialize(#[from] serde_json::Error),
#[error("code push error: {0}")]
CodePush(#[from] CodePushError),
}