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_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}