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