systemprompt_loader/bundle/
provenance.rs1use std::collections::BTreeMap;
16use std::path::Path;
17use std::sync::{Mutex, PoisonError};
18use std::time::SystemTime;
19
20use serde::{Deserialize, Serialize};
21use systemprompt_manifest::profile::Profile;
22use systemprompt_manifest::services::bundle::{BUNDLE_ALLOWED_DIRS, ServicesBundleManifest};
23
24use super::bootstrap::cache_root;
25use super::cache::BundleCache;
26use systemprompt_identifiers::ContentHash;
27use systemprompt_manifest::services::root::{ActiveServicesRoot, ServicesProvenance};
28
29#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(default)]
34pub struct BundleProvenance {
35 pub name: String,
36 pub pinned_digest: Option<String>,
37 pub active_digest: Option<String>,
38 pub content_hash: Option<ContentHash>,
39 pub version: Option<String>,
40}
41
42#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(default)]
45pub struct SourcesProvenance {
46 pub composed_hash: Option<String>,
47 pub base_tree_hash: Option<String>,
48 pub bundles: Vec<BundleProvenance>,
49}
50
51#[derive(Debug, Default)]
54pub struct BaseTreeHashMemo {
55 last: Mutex<Option<(Fingerprint, ContentHash)>>,
56}
57
58impl BaseTreeHashMemo {
59 #[must_use]
60 pub fn new() -> Self {
61 Self::default()
62 }
63}
64
65#[must_use]
66pub fn sources_provenance(
67 profile: &Profile,
68 root: Option<&ActiveServicesRoot>,
69 memo: &BaseTreeHashMemo,
70) -> SourcesProvenance {
71 let cache = BundleCache::new(cache_root(profile));
72 let state = cache.read_state();
73 let bundles: Vec<BundleProvenance> = profile
74 .services
75 .sources
76 .iter()
77 .map(|source| {
78 let active = state.sources.get(&source.name);
79 let reference = source.oci.as_ref().map_or_else(
80 || {
81 source
82 .https
83 .as_ref()
84 .map(|h| h.url.clone())
85 .unwrap_or_default()
86 },
87 |oci| oci.reference.clone(),
88 );
89 BundleProvenance {
90 name: source.name.clone(),
91 pinned_digest: reference
92 .split_once("@sha256:")
93 .map(|(_, digest)| format!("sha256:{digest}")),
94 active_digest: active.map(|a| a.digest.clone()),
95 content_hash: active.map(|a| a.content_hash.clone()),
96 version: active.map(|a| a.version.clone()),
97 }
98 })
99 .collect();
100 let base = base_tree_hash(memo, Path::new(&profile.paths.services)).map(String::from);
101 SourcesProvenance {
102 composed_hash: active_composed_hash(root)
105 .or_else(|| (!state.composed_hash.is_empty()).then(|| state.composed_hash.clone()))
106 .or_else(|| bundles.is_empty().then(|| base.clone()).flatten()),
107 base_tree_hash: base,
108 bundles,
109 }
110}
111
112#[must_use]
116pub fn owning_bundle_hashes(profile: &Profile) -> BTreeMap<String, ContentHash> {
117 let cache = BundleCache::new(cache_root(profile));
118 let state = cache.read_state();
119 let mut owners = BTreeMap::new();
120 for source in &profile.services.sources {
121 let Some(active) = state.sources.get(&source.name) else {
122 continue;
123 };
124 let signed = match cache.read_manifest(&source.name, &active.content_hash) {
125 Ok(signed) => signed,
126 Err(error) => {
127 tracing::warn!(source = %source.name, %error, "Cached bundle manifest unreadable; its skills carry no bundle hash");
128 continue;
129 },
130 };
131 for skill in &signed.manifest.owns.skills {
132 owners.insert(skill.clone(), active.content_hash.clone());
133 }
134 }
135 owners
136}
137
138fn active_composed_hash(root: Option<&ActiveServicesRoot>) -> Option<String> {
139 match root.map(|root| &root.provenance) {
140 Some(
141 ServicesProvenance::Fetched { composed_hash, .. }
142 | ServicesProvenance::LastGood { composed_hash, .. },
143 ) => Some(composed_hash.clone()),
144 _ => None,
145 }
146}
147
148type Fingerprint = (SystemTime, usize);
152
153fn base_tree_hash(memo: &BaseTreeHashMemo, root: &Path) -> Option<ContentHash> {
154 let fingerprint = tree_fingerprint(root)?;
155 if let Some((seen, hash)) = memo
156 .last
157 .lock()
158 .unwrap_or_else(PoisonError::into_inner)
159 .as_ref()
160 && *seen == fingerprint
161 {
162 return Some(hash.clone());
163 }
164 let files = match super::pack::collect_files(root, BUNDLE_ALLOWED_DIRS) {
165 Ok(files) => files,
166 Err(error) => {
167 tracing::warn!(root = %root.display(), %error, "Base services tree unreadable; no base tree hash");
168 return None;
169 },
170 };
171 let hash = ServicesBundleManifest::compute_content_hash(&files);
172 *memo.last.lock().unwrap_or_else(PoisonError::into_inner) = Some((fingerprint, hash.clone()));
173 Some(hash)
174}
175
176fn tree_fingerprint(root: &Path) -> Option<Fingerprint> {
177 let mut newest = SystemTime::UNIX_EPOCH;
178 let mut count = 0usize;
179 for dir in BUNDLE_ALLOWED_DIRS {
180 walk(&root.join(dir), &mut newest, &mut count);
181 }
182 (count > 0).then_some((newest, count))
183}
184
185fn walk(dir: &Path, newest: &mut SystemTime, count: &mut usize) {
186 let Ok(entries) = std::fs::read_dir(dir) else {
187 return;
188 };
189 for entry in entries.flatten() {
190 let path = entry.path();
191 if path.is_dir() {
192 walk(&path, newest, count);
193 } else if let Ok(modified) = entry.metadata().and_then(|meta| meta.modified()) {
194 *count += 1;
195 if modified > *newest {
196 *newest = modified;
197 }
198 }
199 }
200}