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