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    "external_agents",
35    "gateway",
36    "governance",
37    "hooks",
38    "marketplaces",
39    "mcp",
40    "plugins",
41    "rules",
42    "scheduler",
43    "skills",
44    "slack",
45    "web",
46];
47
48pub const MARKETPLACE_BUNDLE_DIRS: &[&str] = &[
49    "marketplaces",
50    "plugins",
51    "skills",
52    "rules",
53    "hooks",
54    "artifacts",
55];
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59pub struct FileEntry {
60    pub path: String,
61
62    #[serde(rename = "checksum")]
63    pub sha256: String,
64
65    pub size: u64,
66}
67
68#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(deny_unknown_fields)]
70pub struct BundleSourceInfo {
71    #[serde(default)]
72    pub repo: Option<String>,
73
74    #[serde(default)]
75    pub commit: Option<String>,
76
77    #[serde(default)]
78    pub workflow_run: Option<String>,
79}
80
81#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(deny_unknown_fields)]
83pub struct BundleOwnership {
84    #[serde(default)]
85    pub marketplaces: Vec<String>,
86
87    #[serde(default)]
88    pub plugins: Vec<String>,
89
90    #[serde(default)]
91    pub skills: Vec<String>,
92
93    #[serde(default)]
94    pub rules: Vec<String>,
95
96    #[serde(default)]
97    pub hooks: Vec<String>,
98
99    #[serde(default)]
100    pub artifacts: Vec<String>,
101
102    #[serde(default)]
103    pub dirs: Vec<String>,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(deny_unknown_fields)]
108pub struct ServicesBundleManifest {
109    pub format: u32,
110
111    pub version: String,
112
113    pub created_at: DateTime<Utc>,
114
115    pub requires_core: String,
116
117    #[serde(default)]
118    pub source: BundleSourceInfo,
119
120    #[serde(default)]
121    pub files: Vec<FileEntry>,
122
123    pub content_hash: String,
124
125    #[serde(default)]
126    pub total_size: u64,
127
128    #[serde(default)]
129    pub owns: BundleOwnership,
130}
131
132impl ServicesBundleManifest {
133    #[must_use]
134    pub fn compute_content_hash(files: &[FileEntry]) -> String {
135        let mut lines: Vec<String> = files
136            .iter()
137            .map(|f| format!("{}\0{}\n", f.path, f.sha256))
138            .collect();
139        lines.sort();
140
141        let mut hasher = Sha256::new();
142        for line in &lines {
143            hasher.update(line.as_bytes());
144        }
145        hex::encode(hasher.finalize())
146    }
147
148    #[must_use]
149    pub fn is_marketplace_only(&self) -> bool {
150        !self.owns.dirs.is_empty()
151            && self
152                .owns
153                .dirs
154                .iter()
155                .all(|d| MARKETPLACE_BUNDLE_DIRS.contains(&d.as_str()))
156    }
157
158    pub fn core_satisfies(&self, core_version: &str) -> Result<bool, semver::Error> {
159        let req = semver::VersionReq::parse(&self.requires_core)?;
160        let version = semver::Version::parse(core_version)?;
161        Ok(req.matches(&version))
162    }
163}
164
165#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
166#[serde(deny_unknown_fields)]
167pub struct BundleSignature {
168    pub alg: String,
169
170    pub key_id: String,
171
172    pub sig_b64: String,
173}
174
175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
176#[serde(deny_unknown_fields)]
177pub struct SignedBundleManifest {
178    pub manifest: ServicesBundleManifest,
179
180    #[serde(default)]
181    pub signature: Option<BundleSignature>,
182}
183
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
185#[serde(deny_unknown_fields)]
186pub struct BundleSourceState {
187    pub digest: String,
188
189    pub version: String,
190
191    pub content_hash: String,
192
193    pub fetched_at: DateTime<Utc>,
194}
195
196#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
197#[serde(deny_unknown_fields)]
198pub struct ServicesBundleState {
199    #[serde(default)]
200    pub composed_hash: String,
201
202    #[serde(default)]
203    pub last_reconciled_hash: Option<String>,
204
205    #[serde(default)]
206    pub sources: BTreeMap<String, BundleSourceState>,
207}