use semver::Version;
use serde::Deserialize;
use serde::Serialize;
use crate::manifest::BundleManifest;
use crate::signing::ReleaseSignature;
pub const INDEX_FILE: &str = "index.json";
pub const RELEASE_FILE: &str = "release.lkb";
pub const SIGNATURE_FILE: &str = "release.sig";
pub const DELTAS_DIR: &str = "deltas";
pub const DELTA_EXTENSION: &str = "jsondelta";
pub const APPROVALS_DIR: &str = "approvals";
pub const APPROVALS_EXTENSION: &str = "json";
pub const INDEX_FORMAT_VERSION: u32 = 1;
pub const INDEX_CACHE_CONTROL: &str = "public, max-age=60";
pub const IMMUTABLE_CACHE_CONTROL: &str = "public, max-age=31536000, immutable";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChannelIndex {
pub format_version: u32,
pub manifest: BundleManifest,
#[serde(default)]
pub signature: Option<ReleaseSignature>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ObjectKind {
Index,
Release,
Signature,
Delta,
Approvals,
}
impl ObjectKind {
pub const fn max_bytes(self) -> u64 {
match self {
Self::Index => 2 * crate::manifest::MAX_MANIFEST_BYTES,
Self::Signature => 4096,
Self::Approvals => crate::provenance::MAX_APPROVALS_BYTES,
Self::Release | Self::Delta => crate::manifest::MAX_CONTAINER_BYTES,
}
}
pub fn content_type(self) -> &'static str {
match self {
Self::Release => "application/octet-stream",
Self::Index | Self::Signature | Self::Delta | Self::Approvals => "application/json",
}
}
pub fn cache_control(self) -> &'static str {
match self {
Self::Index | Self::Approvals => INDEX_CACHE_CONTROL,
Self::Release | Self::Signature | Self::Delta => IMMUTABLE_CACHE_CONTROL,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ObjectKey {
path: String,
kind: ObjectKind,
}
impl ObjectKey {
pub fn index(app: &str, channel: &str) -> Self {
Self {
path: format!("{}/{INDEX_FILE}", channel_path(app, channel)),
kind: ObjectKind::Index,
}
}
pub fn release(app: &str, channel: &str, version: &Version, build: u64) -> Self {
Self {
path: format!(
"{}/{RELEASE_FILE}",
release_path(app, channel, version, build)
),
kind: ObjectKind::Release,
}
}
pub fn signature(app: &str, channel: &str, version: &Version, build: u64) -> Self {
Self {
path: format!(
"{}/{SIGNATURE_FILE}",
release_path(app, channel, version, build)
),
kind: ObjectKind::Signature,
}
}
pub fn delta(
app: &str,
channel: &str,
version: &Version,
build: u64,
base_hash: &str,
) -> Option<Self> {
is_sha256_hex(base_hash).then(|| {
Self {
path: format!(
"{}/{DELTAS_DIR}/{}",
release_path(app, channel, version, build),
delta_file_name(base_hash)
),
kind: ObjectKind::Delta,
}
})
}
pub fn approvals(
app: &str,
channel: &str,
version: &Version,
build: u64,
bundle_hash: &str,
) -> Option<Self> {
is_sha256_hex(bundle_hash).then(|| {
Self {
path: format!(
"{}/{APPROVALS_DIR}/{}",
release_path(app, channel, version, build),
approvals_file_name(bundle_hash)
),
kind: ObjectKind::Approvals,
}
})
}
pub fn approvals_of(manifest: &BundleManifest) -> Self {
Self::approvals(
&manifest.app,
&manifest.channel,
&manifest.version,
manifest.build,
&manifest.bundle_hash(),
)
.expect("a bundle hash is a lowercase hex SHA-256")
}
pub fn as_str(&self) -> &str {
&self.path
}
pub fn kind(&self) -> ObjectKind {
self.kind
}
pub fn url_path(&self) -> String {
self.path
.split('/')
.map(encode_component)
.collect::<Vec<_>>()
.join("/")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StaticObject {
pub key: ObjectKey,
pub bytes: Vec<u8>,
}
pub fn release_name(version: &Version, build: u64) -> String {
format!("{version}+{build}")
}
pub fn parse_release_name(name: &str) -> Option<(Version, u64)> {
let (version, build) = name.rsplit_once('+')?;
let version = Version::parse(version).ok()?;
let build = build.parse().ok()?;
(release_name(&version, build) == name).then_some((version, build))
}
pub(crate) fn delta_file_name(base_hash: &str) -> String {
format!("{base_hash}.{DELTA_EXTENSION}")
}
pub fn parse_delta_file_name(name: &str) -> Option<&str> {
let base_hash = name.strip_suffix(DELTA_EXTENSION)?.strip_suffix('.')?;
is_sha256_hex(base_hash).then_some(base_hash)
}
pub(crate) fn approvals_file_name(bundle_hash: &str) -> String {
format!("{bundle_hash}.{APPROVALS_EXTENSION}")
}
pub fn parse_approvals_file_name(name: &str) -> Option<&str> {
let bundle_hash = name.strip_suffix(APPROVALS_EXTENSION)?.strip_suffix('.')?;
is_sha256_hex(bundle_hash).then_some(bundle_hash)
}
pub(crate) fn channel_path(app: &str, channel: &str) -> String {
format!("{}/{}", sanitize(app), sanitize(channel))
}
pub(crate) fn release_path(app: &str, channel: &str, version: &Version, build: u64) -> String {
format!(
"{}/{}",
channel_path(app, channel),
release_name(version, build)
)
}
fn sanitize(component: &str) -> String {
let sanitized: String = component
.chars()
.map(|character| {
if character.is_ascii_alphanumeric() || character == '-' || character == '_' {
character
} else {
'_'
}
})
.collect();
if sanitized.is_empty() {
"_".to_owned()
} else {
sanitized
}
}
fn is_sha256_hex(hash: &str) -> bool {
hash.len() == 64
&& hash
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}
pub(crate) fn encode_component(component: &str) -> String {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
let mut encoded = String::with_capacity(component.len());
for byte in component.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
encoded.push(char::from(byte));
} else {
encoded.push('%');
encoded.push(char::from(HEX[usize::from(byte >> 4)]));
encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
}
}
encoded
}
#[cfg(test)]
mod tests {
use super::*;
const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
#[test]
fn keys_follow_the_layout() {
let version = Version::parse("1.2.0-beta.1+abc").expect("version");
assert_eq!(
ObjectKey::index("my app", "production").as_str(),
"my_app/production/index.json"
);
assert_eq!(
ObjectKey::release("app", "production", &version, 3).as_str(),
"app/production/1.2.0-beta.1+abc+3/release.lkb"
);
assert_eq!(
ObjectKey::signature("app", "production", &version, 3).as_str(),
"app/production/1.2.0-beta.1+abc+3/release.sig"
);
assert_eq!(
ObjectKey::delta("app", "production", &version, 3, HASH)
.expect("valid hash")
.as_str(),
format!("app/production/1.2.0-beta.1+abc+3/deltas/{HASH}.jsondelta")
);
let approvals =
ObjectKey::approvals("app", "production", &version, 3, HASH).expect("valid hash");
assert_eq!(
approvals.as_str(),
format!("app/production/1.2.0-beta.1+abc+3/approvals/{HASH}.json")
);
assert_eq!(approvals.kind(), ObjectKind::Approvals);
assert_eq!(approvals.kind().cache_control(), INDEX_CACHE_CONTROL);
}
#[test]
fn approvals_file_names_carry_a_bundle_hash() {
assert_eq!(
parse_approvals_file_name(&format!("{HASH}.json")),
Some(HASH)
);
assert_eq!(
parse_approvals_file_name(&format!("{HASH}.jsondelta")),
None
);
assert_eq!(parse_approvals_file_name("x.json"), None);
assert!(
ObjectKey::approvals("app", "production", &Version::new(1, 0, 0), 1, "../x").is_none()
);
}
#[test]
fn url_paths_escape_the_build_separator() {
let key = ObjectKey::release("app", "production", &Version::new(1, 0, 0), 2);
assert_eq!(key.url_path(), "app/production/1.0.0%2B2/release.lkb");
}
#[test]
fn names_that_could_escape_a_directory_are_sanitized_or_rejected() {
assert_eq!(
ObjectKey::index("..", "../etc").as_str(),
"__/___etc/index.json"
);
assert_eq!(ObjectKey::index("", "").as_str(), "_/_/index.json");
for hash in [
"",
"..",
&HASH.to_uppercase(),
&HASH[1..],
"../../../index.json",
] {
assert!(
ObjectKey::delta("app", "production", &Version::new(1, 0, 0), 1, hash).is_none(),
"{hash:?} must be rejected"
);
}
}
#[test]
fn release_names_round_trip_only_in_canonical_form() {
let version = Version::parse("1.0.0+abc").expect("version");
assert_eq!(
parse_release_name(&release_name(&version, 7)),
Some((version, 7))
);
for name in [
"1.0.0",
"1.0.0+",
"1.0.0+x",
"../1.0.0+1",
"1.0.0+01",
"v1.0.0+1",
] {
assert_eq!(parse_release_name(name), None, "{name:?}");
}
}
#[test]
fn delta_file_names_carry_a_sha256() {
assert_eq!(
parse_delta_file_name(&format!("{HASH}.jsondelta")),
Some(HASH)
);
assert_eq!(parse_delta_file_name(&format!("{HASH}jsondelta")), None);
assert_eq!(parse_delta_file_name(&format!("{HASH}.json")), None);
assert_eq!(parse_delta_file_name("x.jsondelta"), None);
}
}