use std::collections::BTreeMap;
use std::path::{Component, Path, PathBuf};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use thiserror::Error;
use crate::{Entry, EntryArtifact, EntryArtifactPath, EntryArtifactPathError};
pub const CHARM_MANIFEST_FILE_NAME: &str = "Sirno.charm.toml";
pub const SPELL_CACHE_DIRECTORY: &str = ".sirno/spells";
#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CharmCommandSpec {
pub command: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CharmBuildSpec {
pub command: Vec<String>,
#[serde(default)]
pub output: Option<PathBuf>,
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SpellSpec {
pub command: Vec<String>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct CharmPreparationSpec {
pub setup: Option<CharmCommandSpec>,
pub check: Option<CharmCommandSpec>,
pub build: Option<CharmBuildSpec>,
}
#[derive(Clone, Debug, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CharmManifest {
pub spell: SpellSpec,
#[serde(default)]
pub charm: CharmPreparationSpec,
#[serde(default)]
pub hooks: Vec<String>,
#[serde(default)]
pub inputs: Option<toml::Value>,
}
impl CharmManifest {
pub fn from_bytes(bytes: &[u8]) -> Result<Self, CharmError> {
let source = std::str::from_utf8(bytes).map_err(CharmError::ManifestUtf8)?;
let manifest: Self = toml::from_str(source).map_err(CharmError::ManifestParse)?;
manifest.validate()?;
Ok(manifest)
}
pub fn is_source(&self) -> bool {
self.charm.build.is_some()
}
fn validate(&self) -> Result<(), CharmError> {
validate_argv("spell.command", &self.spell.command)?;
if let Some(setup) = &self.charm.setup {
validate_argv("charm.setup.command", &setup.command)?;
}
if let Some(check) = &self.charm.check {
validate_argv("charm.check.command", &check.command)?;
}
if let Some(build) = &self.charm.build {
validate_argv("charm.build.command", &build.command)?;
if let Some(output) = &build.output {
validate_relative_output(output)?;
}
}
Ok(())
}
}
#[derive(Clone, Debug)]
pub struct CharmBundle {
pub entry: Entry,
pub manifest: CharmManifest,
pub artifact_root: PathBuf,
pub artifacts: BTreeMap<EntryArtifactPath, Vec<u8>>,
}
impl CharmBundle {
pub fn is_source(&self) -> bool {
self.manifest.is_source()
}
pub fn kind_label(&self) -> &'static str {
if self.is_source() { "source" } else { "direct" }
}
pub fn fingerprint(&self) -> String {
let mut hasher = Sha256::new();
hasher.update(b"sirno-charm-v1\n");
hasher.update(self.entry.to_markdown().expect("entry markdown rendering is infallible"));
hasher.update(b"\n");
for (path, content) in &self.artifacts {
hasher.update(path.as_str().as_bytes());
hasher.update(b"\0");
hasher.update(content.len().to_string().as_bytes());
hasher.update(b"\0");
hasher.update(content);
hasher.update(b"\n");
}
hex_digest(hasher.finalize().as_slice())
}
}
pub fn manifest_artifact_path() -> EntryArtifactPath {
EntryArtifactPath::new(CHARM_MANIFEST_FILE_NAME).expect("manifest filename is a valid path")
}
pub fn artifact_map(artifacts: Vec<EntryArtifact>) -> BTreeMap<EntryArtifactPath, Vec<u8>> {
artifacts.into_iter().map(|artifact| (artifact.path, artifact.content)).collect()
}
fn validate_argv(field: &'static str, argv: &[String]) -> Result<(), CharmError> {
if argv.is_empty() {
return Err(CharmError::EmptyCommand(field));
}
if argv.iter().any(|arg| arg.is_empty()) {
return Err(CharmError::EmptyCommandArgument(field));
}
Ok(())
}
fn validate_relative_output(path: &Path) -> Result<(), CharmError> {
if path.as_os_str().is_empty() {
return Err(CharmError::InvalidBuildOutput(path.to_path_buf()));
}
if path.is_absolute()
|| path.components().any(|component| !matches!(component, Component::Normal(_)))
{
return Err(CharmError::InvalidBuildOutput(path.to_path_buf()));
}
Ok(())
}
fn hex_digest(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push(HEX[(byte >> 4) as usize] as char);
out.push(HEX[(byte & 0x0f) as usize] as char);
}
out
}
#[derive(Debug, Error)]
pub enum CharmError {
#[error("entry `{0}` has no {CHARM_MANIFEST_FILE_NAME} artifact")]
MissingManifest(crate::EntryAddress),
#[error("{CHARM_MANIFEST_FILE_NAME} must be UTF-8")]
ManifestUtf8(#[source] std::str::Utf8Error),
#[error("failed to parse {CHARM_MANIFEST_FILE_NAME}: {0}")]
ManifestParse(#[source] toml::de::Error),
#[error("{0} must contain at least one argv element")]
EmptyCommand(&'static str),
#[error("{0} must not contain empty argv elements")]
EmptyCommandArgument(&'static str),
#[error("charm.build.output must be a normal relative path: {0}")]
InvalidBuildOutput(PathBuf),
#[error(transparent)]
ArtifactPath(#[from] EntryArtifactPathError),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_direct_manifest() {
let manifest = CharmManifest::from_bytes(
br#"
hooks = ["example"]
[spell]
command = ["sh", "script.sh"]
[charm.check]
command = ["sh", "-n", "script.sh"]
"#,
)
.unwrap();
assert!(!manifest.is_source());
assert_eq!(manifest.spell.command, vec!["sh", "script.sh"]);
assert_eq!(manifest.hooks, vec!["example"]);
}
#[test]
fn parses_source_manifest() {
let manifest = CharmManifest::from_bytes(
br#"
[spell]
command = ["tool"]
[charm.build]
command = ["cargo", "build"]
output = "target/debug/tool"
"#,
)
.unwrap();
assert!(manifest.is_source());
assert_eq!(manifest.charm.build.unwrap().output, Some(PathBuf::from("target/debug/tool")));
}
#[test]
fn rejects_empty_spell_command() {
let error = CharmManifest::from_bytes(
br#"
[spell]
command = []
"#,
)
.unwrap_err();
assert!(error.to_string().contains("spell.command"));
}
}