use color_eyre::eyre::{Report, eyre};
use postgresql_embedded::Version;
use serde::Deserialize;
use super::{ExtensionName, Sha256Hex, extension_error, http::is_permitted_url};
use crate::error::{BootstrapError, BootstrapErrorKind, BootstrapResult};
mod fetch;
mod select;
mod source;
pub(super) use self::fetch::load;
pub use self::{
select::{ArtifactQuery, Selection},
source::ManifestSource,
};
pub const SUPPORTED_SCHEMA_VERSION: u32 = 1;
pub(super) const MANIFEST_SIZE_CAP: u64 = 1024 * 1024;
pub(super) const ARCHIVE_SIZE_CAP: u64 = 256 * 1024 * 1024;
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct Manifest {
pub schema_version: u32,
pub release: String,
pub generated_at: String,
pub extensions: Vec<ManifestExtension>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct ManifestExtension {
pub name: String,
pub package: String,
pub version: String,
pub source: ManifestSourceInfo,
pub artifacts: Vec<ManifestArtifact>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct ManifestSourceInfo {
pub repository: String,
pub tag: String,
pub commit: String,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct ManifestArtifact {
pub postgresql: String,
pub target: String,
pub file: String,
pub url: String,
#[serde(deserialize_with = "deserialize_digest")]
pub sha256: Sha256Hex,
pub size: u64,
pub files: Vec<String>,
}
fn deserialize_digest<'de, D>(deserializer: D) -> Result<Sha256Hex, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
Sha256Hex::parse(&raw).map_err(serde::de::Error::custom)
}
impl Manifest {
pub fn parse(bytes: &[u8]) -> BootstrapResult<Self> {
let manifest: Self = serde_json::from_slice(bytes)
.map_err(|err| invalid(eyre!("manifest is not valid JSON for schema 1: {err}")))?;
manifest.validate()?;
Ok(manifest)
}
fn validate(&self) -> BootstrapResult<()> {
if self.schema_version != SUPPORTED_SCHEMA_VERSION {
return Err(invalid(eyre!(
"manifest schema_version {} is not supported; this crate understands {}",
self.schema_version,
SUPPORTED_SCHEMA_VERSION
)));
}
for extension in &self.extensions {
ExtensionName::new(extension.name.as_str())
.map_err(|err| invalid(eyre!("manifest extension name: {err}")))?;
for artifact in &extension.artifacts {
validate_artifact(&extension.name, artifact)?;
}
}
Ok(())
}
fn extension_names(&self) -> Vec<&str> {
self.extensions
.iter()
.map(|extension| extension.name.as_str())
.collect()
}
}
fn validate_artifact(name: &str, artifact: &ManifestArtifact) -> BootstrapResult<()> {
artifact_version(artifact).ok_or_else(|| {
invalid(eyre!(
"{name}: artefact postgresql {:?} is not a version",
artifact.postgresql
))
})?;
if !is_single_component(&artifact.file) {
return Err(invalid(eyre!(
"{name}: artefact file {:?} must be a bare file name",
artifact.file
)));
}
if artifact.size == 0 || artifact.size > ARCHIVE_SIZE_CAP {
return Err(invalid(eyre!(
"{name}: artefact {} declares size {} bytes; the size must be between 1 and \
{ARCHIVE_SIZE_CAP}",
artifact.file,
artifact.size
)));
}
if artifact.files.is_empty() {
return Err(invalid(eyre!(
"{name}: artefact {} lists no files",
artifact.file
)));
}
if !is_permitted_url(&artifact.url) {
return Err(invalid(eyre!(
"{name}: artefact url {:?} must use https:// (loopback http is the only exception)",
super::http::redact_url(&artifact.url)
)));
}
Ok(())
}
fn is_single_component(file: &str) -> bool {
!file.is_empty() && !file.contains('/') && !file.contains('\\') && file != "." && file != ".."
}
fn artifact_version(artifact: &ManifestArtifact) -> Option<Version> {
Version::parse(&artifact.postgresql).ok()
}
const fn invalid(report: Report) -> BootstrapError {
extension_error(BootstrapErrorKind::ExtensionManifestInvalid, report)
}
const fn unavailable(report: Report) -> BootstrapError {
extension_error(BootstrapErrorKind::ExtensionUnavailable, report)
}
const fn unavailable_manifest(report: Report) -> BootstrapError {
extension_error(BootstrapErrorKind::ExtensionManifestUnavailable, report)
}