systemprompt_loader/bundle/
compose.rs1use std::collections::HashMap;
17use std::fs;
18use std::path::{Path, PathBuf};
19
20use sha2::{Digest, Sha256};
21use systemprompt_models::services::bundle::{
22 BUNDLE_MANIFEST_FILE, MARKETPLACE_BUNDLE_DIRS, ServicesBundleManifest,
23};
24
25use super::cache::{BundleCache, discard_staging};
26use super::error::{BundleError, BundleResult};
27use super::verify::require_marketplace_only;
28
29#[derive(Debug)]
30pub struct BundleMember<'a> {
31 pub name: String,
32 pub content_hash: String,
33 pub manifest: &'a ServicesBundleManifest,
34}
35
36#[must_use]
37pub fn composed_hash(members: &[BundleMember<'_>]) -> String {
38 let mut hasher = Sha256::new();
39 for member in members {
40 hasher.update(format!("{}\0{}\n", member.name, member.content_hash).as_bytes());
41 }
42 hex::encode(hasher.finalize())
43}
44
45pub fn compose(
46 cache: &BundleCache,
47 members: &[BundleMember<'_>],
48) -> BundleResult<(PathBuf, String)> {
49 let hash = composed_hash(members);
50 let target = cache.composed_dir(&hash);
51 if target.is_dir() {
52 return Ok((target, hash));
53 }
54
55 check_ownership(members)?;
56
57 cache.prepare()?;
58 let staging = cache.composed_dir(&format!("{hash}.tmp-{}", std::process::id()));
59 if staging.exists() {
60 fs::remove_dir_all(&staging)?;
61 }
62 fs::create_dir_all(&staging)?;
63
64 for member in members {
65 let source = cache.bundle_dir(&member.name, &member.content_hash);
66 copy_tree(&source, &staging, true)?;
67 }
68
69 match fs::rename(&staging, &target) {
70 Ok(()) => Ok((target, hash)),
71 Err(e) if target.is_dir() => {
72 discard_staging(&staging);
73 tracing::debug!(error = %e, hash = %hash, "Composed root already published");
74 Ok((target, hash))
75 },
76 Err(e) => {
77 discard_staging(&staging);
78 Err(BundleError::extract(&target, e))
79 },
80 }
81}
82
83fn check_ownership(members: &[BundleMember<'_>]) -> BundleResult<()> {
84 for member in members.iter().skip(1) {
85 require_marketplace_only(member.manifest)?;
86 }
87
88 let mut owners: HashMap<(&str, String), &str> = HashMap::new();
89 for member in members {
90 let owns = &member.manifest.owns;
91 let categories: [(&str, &Vec<String>); 6] = [
92 ("marketplace", &owns.marketplaces),
93 ("plugin", &owns.plugins),
94 ("skill", &owns.skills),
95 ("rule", &owns.rules),
96 ("hook", &owns.hooks),
97 ("artifact", &owns.artifacts),
98 ];
99 for (kind, ids) in categories {
100 for id in ids {
101 claim(&mut owners, kind, id.clone(), &member.name)?;
102 }
103 }
104 for dir in &owns.dirs {
105 if MARKETPLACE_BUNDLE_DIRS.contains(&dir.as_str()) {
106 continue;
107 }
108 claim(&mut owners, "directory", dir.clone(), &member.name)?;
109 }
110 }
111 Ok(())
112}
113
114fn claim<'a>(
115 owners: &mut HashMap<(&'a str, String), &'a str>,
116 kind: &'a str,
117 id: String,
118 name: &'a str,
119) -> BundleResult<()> {
120 if let Some(first) = owners.insert((kind, id.clone()), name) {
121 return Err(BundleError::Ownership {
122 id,
123 kind: kind.to_owned(),
124 first: first.to_owned(),
125 second: name.to_owned(),
126 });
127 }
128 Ok(())
129}
130
131pub(crate) fn copy_tree(source: &Path, dest: &Path, skip_manifest: bool) -> BundleResult<()> {
132 for entry in fs::read_dir(source)? {
133 let path = entry?.path();
134 let Some(name) = path.file_name() else {
135 continue;
136 };
137 if skip_manifest && path.is_file() && name == BUNDLE_MANIFEST_FILE {
138 continue;
139 }
140 let target = dest.join(name);
141 if path.is_dir() {
142 fs::create_dir_all(&target)?;
143 copy_tree(&path, &target, false)?;
144 } else if path.is_file() {
145 if let Some(parent) = target.parent() {
146 fs::create_dir_all(parent)?;
147 }
148 if fs::hard_link(&path, &target).is_err() {
149 fs::copy(&path, &target)?;
150 }
151 }
152 }
153 Ok(())
154}