Skip to main content

systemprompt_models/services/
bundle.rs

1//! Manifest, ownership and state types for a packaged services bundle.
2//!
3//! A bundle is a gzipped tar holding [`BUNDLE_MANIFEST_FILE`] and a
4//! `services/` subtree. The manifest is the verification surface: the archive
5//! digest pins the bytes, the optional signature attests the publisher, the
6//! per-file `sha256` values check extraction, and
7//! [`ServicesBundleManifest::content_hash`] keys the cache and the authz
8//! reconcile.
9//!
10//! [`FileEntry`] serialises its digest under the key `checksum`, the name the
11//! services download manifest has always used on the wire.
12//!
13//! Copyright (c) systemprompt.io — Business Source License 1.1.
14//! See <https://systemprompt.io> for licensing details.
15
16use std::collections::BTreeMap;
17
18use chrono::{DateTime, Utc};
19use serde::{Deserialize, Serialize};
20use sha2::{Digest, Sha256};
21
22pub const BUNDLE_MANIFEST_FILE: &str = "bundle.json";
23pub const BUNDLE_FORMAT_VERSION: u32 = 1;
24pub const BUNDLE_MEDIA_TYPE: &str = "application/vnd.systemprompt.services-bundle.v1.tar+gzip";
25pub const BUNDLE_SIGNATURE_ALG: &str = "ed25519";
26
27pub const BUNDLE_ALLOWED_DIRS: &[&str] = &[
28    "access-control",
29    "agents",
30    "ai",
31    "artifacts",
32    "config",
33    "content",
34    "evaluation",
35    "external_agents",
36    "gateway",
37    "governance",
38    "hooks",
39    "marketplaces",
40    "mcp",
41    "plugins",
42    "rules",
43    "scheduler",
44    "skills",
45    "slack",
46    "web",
47];
48
49pub const MARKETPLACE_BUNDLE_DIRS: &[&str] = &[
50    "marketplaces",
51    "plugins",
52    "skills",
53    "rules",
54    "hooks",
55    "artifacts",
56];
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(deny_unknown_fields)]
60pub struct FileEntry {
61    pub path: String,
62
63    #[serde(rename = "checksum")]
64    pub sha256: String,
65
66    pub size: u64,
67}
68
69#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(deny_unknown_fields)]
71pub struct BundleSourceInfo {
72    #[serde(default)]
73    pub repo: Option<String>,
74
75    #[serde(default)]
76    pub commit: Option<String>,
77
78    #[serde(default)]
79    pub workflow_run: Option<String>,
80}
81
82#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(deny_unknown_fields)]
84pub struct BundleOwnership {
85    #[serde(default)]
86    pub marketplaces: Vec<String>,
87
88    #[serde(default)]
89    pub plugins: Vec<String>,
90
91    #[serde(default)]
92    pub skills: Vec<String>,
93
94    #[serde(default)]
95    pub rules: Vec<String>,
96
97    #[serde(default)]
98    pub hooks: Vec<String>,
99
100    #[serde(default)]
101    pub artifacts: Vec<String>,
102
103    #[serde(default)]
104    pub dirs: Vec<String>,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(deny_unknown_fields)]
109pub struct ServicesBundleManifest {
110    pub format: u32,
111
112    pub version: String,
113
114    pub created_at: DateTime<Utc>,
115
116    pub requires_core: String,
117
118    #[serde(default)]
119    pub source: BundleSourceInfo,
120
121    #[serde(default)]
122    pub files: Vec<FileEntry>,
123
124    pub content_hash: String,
125
126    #[serde(default)]
127    pub total_size: u64,
128
129    #[serde(default)]
130    pub owns: BundleOwnership,
131}
132
133impl ServicesBundleManifest {
134    #[must_use]
135    pub fn compute_content_hash(files: &[FileEntry]) -> String {
136        let mut lines: Vec<String> = files
137            .iter()
138            .map(|f| format!("{}\0{}\n", f.path, f.sha256))
139            .collect();
140        lines.sort();
141
142        let mut hasher = Sha256::new();
143        for line in &lines {
144            hasher.update(line.as_bytes());
145        }
146        hex::encode(hasher.finalize())
147    }
148
149    #[must_use]
150    pub fn is_marketplace_only(&self) -> bool {
151        !self.owns.dirs.is_empty()
152            && self
153                .owns
154                .dirs
155                .iter()
156                .all(|d| MARKETPLACE_BUNDLE_DIRS.contains(&d.as_str()))
157    }
158
159    pub fn core_satisfies(&self, core_version: &str) -> Result<bool, semver::Error> {
160        let req = semver::VersionReq::parse(&self.requires_core)?;
161        let version = semver::Version::parse(core_version)?;
162        Ok(req.matches(&version))
163    }
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(deny_unknown_fields)]
168pub struct BundleSignature {
169    pub alg: String,
170
171    pub key_id: String,
172
173    pub sig_b64: String,
174}
175
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
177#[serde(deny_unknown_fields)]
178pub struct SignedBundleManifest {
179    pub manifest: ServicesBundleManifest,
180
181    #[serde(default)]
182    pub signature: Option<BundleSignature>,
183}
184
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
186#[serde(deny_unknown_fields)]
187pub struct BundleSourceState {
188    pub digest: String,
189
190    pub version: String,
191
192    pub content_hash: String,
193
194    pub fetched_at: DateTime<Utc>,
195}
196
197#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
198#[serde(deny_unknown_fields)]
199pub struct ServicesBundleState {
200    #[serde(default)]
201    pub composed_hash: String,
202
203    #[serde(default)]
204    pub last_reconciled_hash: Option<String>,
205
206    #[serde(default)]
207    pub sources: BTreeMap<String, BundleSourceState>,
208}