Skip to main content

systemprompt_loader/bundle/
compose.rs

1//! Overlaying several verified bundles into one services root.
2//!
3//! Composition is by-id, not last-write-wins: two bundles claiming the same
4//! marketplace, plugin, skill, rule, hook or artifact is a boot error naming
5//! both sources, because silently preferring one would make which access
6//! rules an instance enforces depend on profile ordering. Marketplace
7//! directories are shared by construction — their ids are disjoint — while a
8//! base-only directory such as `access-control/` may have exactly one owner.
9//!
10//! The composed root is content-addressed by the ordered list of member
11//! hashes, so recomposing an unchanged set is a directory-exists check.
12//!
13//! Copyright (c) systemprompt.io — Business Source License 1.1.
14//! See <https://systemprompt.io> for licensing details.
15
16use 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}