use std::{fs, path::Path, sync::Arc};
use serde::Serialize;
use wyrd_spec::{error::WyrdError, reference::CardRef};
use crate::cards::{
RegistryContext, download::download_artifact, download_progress::DownloadProgressDisplay,
error::RegistryEngineError,
};
use super::{
BundleArtifactManifest, BundleCardManifest, BundleManifest, BundleMode,
graph::{ResolvedCard, ResolvedGraph, graph_error, validate_alias},
};
pub(super) struct BundleStats {
pub(super) artifact_count: usize,
pub(super) downloaded_artifact_count: usize,
}
struct CardDocumentPaths<'a> {
card: &'a Path,
relationships: &'a Path,
}
pub(super) struct BundleWriter<'a> {
context: &'a RegistryContext,
staging: &'a Path,
mode: BundleMode,
}
impl<'a> BundleWriter<'a> {
pub(super) fn new(context: &'a RegistryContext, staging: &'a Path, mode: BundleMode) -> Self {
Self {
context,
staging,
mode,
}
}
pub(super) async fn write(&self, graph: &ResolvedGraph) -> Result<BundleStats, WyrdError> {
let display = matches!(self.mode, BundleMode::Complete)
.then(|| Arc::new(DownloadProgressDisplay::hidden()));
let result = async {
let mut manifests = Vec::with_capacity(graph.cards.len());
let mut downloaded_artifact_count = 0;
for card in &graph.cards {
let (manifest, downloaded) = self.write_card(card, display.as_ref()).await?;
manifests.push(manifest);
downloaded_artifact_count += downloaded;
}
let artifact_count = manifests.iter().map(|card| card.artifacts.len()).sum();
let manifest = BundleManifest {
api_version: "wyrd/bundle/v1".to_owned(),
mode: self.mode,
root: graph.root.clone(),
card_count: manifests.len(),
artifact_count,
downloaded_artifact_count,
cards: manifests,
};
write_yaml(&self.staging.join("metadata.yaml"), &manifest)?;
Ok(BundleStats {
artifact_count,
downloaded_artifact_count,
})
}
.await;
if let Some(display) = display {
display.clear();
}
result
}
async fn write_card(
&self,
card: &ResolvedCard,
display: Option<&Arc<DownloadProgressDisplay>>,
) -> Result<(BundleCardManifest, usize), WyrdError> {
let canonical_alias = card
.aliases
.iter()
.next()
.ok_or_else(|| graph_error("pulled Card has no bundle alias", &card.card_ref))?;
validate_alias(canonical_alias)?;
let card_dir = self.staging.join("cards").join(canonical_alias);
let card_path = card_dir.join("card.yaml");
let relationships_path = card_dir.join("relationships.yaml");
let inventory_path = card_dir.join("artifacts.yaml");
self.write_card_documents(
card,
CardDocumentPaths {
card: &card_path,
relationships: &relationships_path,
},
)?;
let (artifacts, downloaded) = self.materialize_artifacts(card, &card_dir, display).await?;
write_yaml(&inventory_path, &artifacts)?;
let aliases = self.write_aliases(card, &card_path)?;
Ok((
BundleCardManifest {
aliases,
card_ref: card.card_ref.clone(),
card_path: relative_path(self.staging, &card_path)?,
relationships_path: relative_path(self.staging, &relationships_path)?,
artifact_inventory_path: relative_path(self.staging, &inventory_path)?,
artifacts,
},
downloaded,
))
}
fn write_card_documents(
&self,
card: &ResolvedCard,
paths: CardDocumentPaths<'_>,
) -> Result<(), WyrdError> {
write_yaml(paths.card, &card.card)?;
write_yaml(paths.relationships, &card.card.relationships)?;
Ok(())
}
async fn materialize_artifacts(
&self,
card: &ResolvedCard,
card_dir: &Path,
display: Option<&Arc<DownloadProgressDisplay>>,
) -> Result<(Vec<BundleArtifactManifest>, usize), WyrdError> {
let mut artifacts = Vec::with_capacity(card.inventory.artifacts.len());
let mut downloaded = 0;
for entry in &card.inventory.artifacts {
validate_artifact_path(entry.relative_path.as_str(), &card.card_ref)?;
let local_path = if matches!(self.mode, BundleMode::Complete) {
let display = display.ok_or_else(|| {
graph_error(
"complete pull is missing its download display",
&card.card_ref,
)
})?;
let path = card_dir
.join("artifacts")
.join(entry.relative_path.as_str());
if !path.starts_with(self.staging) {
return Err(graph_error(
"artifact path escaped pull staging",
&card.card_ref,
));
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(RegistryEngineError::from)
.map_err(WyrdError::from)?;
}
let uid = card.card_ref.uid.as_ref().ok_or_else(|| {
graph_error("artifact-bearing Card is missing its UID", &card.card_ref)
})?;
download_artifact(
&self.context.engine.client,
&self.context.engine.storage,
uid,
&entry.relative_path,
&path,
Arc::clone(display),
)
.await
.map_err(WyrdError::from)?;
downloaded += 1;
Some(relative_path(self.staging, &path)?)
} else {
None
};
artifacts.push(BundleArtifactManifest {
relative_path: entry.relative_path.as_str().to_owned(),
sha256: entry.sha256.clone(),
size_bytes: entry.size_bytes,
content_type: entry.content_type.clone(),
local_path,
});
}
Ok((artifacts, downloaded))
}
fn write_aliases(
&self,
card: &ResolvedCard,
card_path: &Path,
) -> Result<Vec<String>, WyrdError> {
let aliases = card.aliases.iter().cloned().collect::<Vec<_>>();
for alias in &aliases {
let alias_path = self.staging.join("aliases").join(format!("{alias}.yaml"));
let alias_record = serde_json::json!({
"alias": alias,
"card_ref": card.card_ref,
"card_path": relative_path(self.staging, card_path)?,
});
write_yaml(&alias_path, &alias_record)?;
}
Ok(aliases)
}
}
fn write_yaml<T: Serialize>(path: &Path, value: &T) -> Result<(), WyrdError> {
let parent = path.parent().ok_or_else(|| WyrdError::Internal {
message: "pull output path has no parent".to_owned(),
details: serde_json::json!({ "path": path }),
})?;
fs::create_dir_all(parent)
.map_err(RegistryEngineError::from)
.map_err(WyrdError::from)?;
let yaml = serde_json::to_value(value)
.map_err(|error| error.to_string())
.and_then(|value| serde_yaml::to_string(&value).map_err(|error| error.to_string()))
.map_err(|error| WyrdError::Internal {
message: "failed to serialize Card bundle YAML".to_owned(),
details: serde_json::json!({ "path": path, "error": error }),
})?;
fs::write(path, yaml)
.map_err(RegistryEngineError::from)
.map_err(WyrdError::from)
}
fn relative_path(root: &Path, path: &Path) -> Result<String, WyrdError> {
path.strip_prefix(root)
.map_err(|_| WyrdError::RegistryInvalidCardSpec {
message: "pulled path escaped bundle destination".to_owned(),
details: serde_json::json!({ "path": path, "root": root }),
})
.map(|path| path.to_string_lossy().replace('\\', "/"))
}
fn validate_artifact_path(path: &str, card_ref: &CardRef) -> Result<(), WyrdError> {
if path.is_empty()
|| path.starts_with('/')
|| path.contains('\\')
|| path.split('/').any(|segment| {
segment.is_empty()
|| segment == "."
|| segment == ".."
|| segment.bytes().any(
|byte| !matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'.' | b'_' | b'-'),
)
})
{
return Err(graph_error(
"artifact path is not confined to the Card bundle",
card_ref,
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use wyrd_semver::VersionBlock;
use wyrd_spec::{
envelope::CardKind,
ids::{CardName, SpaceName},
reference::CardRef,
};
use super::{validate_artifact_path, write_yaml};
#[test]
fn artifact_paths_are_validated_in_metadata_mode() {
let card_ref = CardRef {
kind: CardKind::Prompt,
name: CardName::new("welcome").expect("test name is valid"),
version: VersionBlock::parse("1.0.0").expect("test version is valid"),
space: Some(SpaceName::new("default").expect("test space is valid")),
uid: None,
};
assert!(validate_artifact_path("nested/file.bin", &card_ref).is_ok());
assert!(validate_artifact_path("../escape", &card_ref).is_err());
assert!(validate_artifact_path("nested/file name.bin", &card_ref).is_err());
}
#[test]
fn tagged_enums_are_written_as_mappings() {
let dir = tempfile::tempdir().expect("temp dir creates");
let path = dir.path().join("card.yaml");
let value = skald_spec::ResponseType::JsonSchema {
name: "judge".to_owned(),
schema: serde_json::json!({ "type": "object" }),
};
write_yaml(&path, &value).expect("the document writes");
let read: serde_json::Value =
serde_yaml::from_slice(&std::fs::read(&path).expect("the document reads"))
.expect("the document is JSON-model YAML");
assert_eq!(read["json_schema"]["name"], "judge");
}
}