greentic_component_store/
lib.rs1use std::fs as std_fs;
2use std::path::{Path, PathBuf};
3
4use percent_encoding::percent_decode_str;
5use sha2::{Digest as _, Sha256};
6use thiserror::Error;
7use tracing::debug;
8use url::Url;
9
10pub mod fs;
11#[cfg(feature = "http")]
12pub mod http;
13pub mod oci;
14pub mod verify;
15pub mod warg;
16
17pub use verify::{
18 DigestAlgorithm, DigestPolicy, SignaturePolicy, VerificationError, VerificationPolicy,
19 VerificationReport, VerifiedDigest, VerifiedSignature,
20};
21
22#[derive(Debug, Clone)]
23pub struct ComponentStore {
24 cache_root: PathBuf,
25 #[cfg(feature = "http")]
26 http_client: reqwest::blocking::Client,
27}
28
29impl ComponentStore {
30 pub fn new(cache_root: impl AsRef<Path>) -> Result<Self, StoreError> {
31 let cache_root = cache_root.as_ref().to_path_buf();
32 std_fs::create_dir_all(&cache_root)?;
33 Ok(Self {
34 cache_root,
35 #[cfg(feature = "http")]
36 http_client: http::build_client()?,
37 })
38 }
39
40 pub fn with_default_cache() -> Result<Self, StoreError> {
41 let default = default_cache_dir();
42 Self::new(default)
43 }
44
45 pub fn cache_root(&self) -> &Path {
46 &self.cache_root
47 }
48
49 pub fn fetch_from_str(
50 &self,
51 locator: &str,
52 policy: &VerificationPolicy,
53 ) -> Result<StoreArtifact, StoreError> {
54 let locator = StoreLocator::parse(locator)?;
55 self.fetch(&locator, policy)
56 }
57
58 pub fn fetch(
59 &self,
60 locator: &StoreLocator,
61 policy: &VerificationPolicy,
62 ) -> Result<StoreArtifact, StoreError> {
63 if let Some(expected) = policy.digest.as_ref().and_then(|d| d.expected()) {
64 let cache_path = self.cache_root.join(format!("{expected}.wasm"));
65 if cache_path.exists() {
66 debug!("cache hit for digest {expected}");
67 let bytes = std_fs::read(&cache_path)?;
68 let report = policy.verify(&bytes)?;
69 return Ok(StoreArtifact {
70 locator: locator.clone(),
71 path: cache_path,
72 bytes,
73 verification: report,
74 });
75 }
76 }
77
78 if let Some(artifact) = self.try_fetch_cached(locator, policy)? {
79 return Ok(artifact);
80 }
81
82 let bytes = self.fetch_bytes(locator)?;
83 let report = policy.verify(&bytes)?;
84 let digest = report
85 .digest
86 .clone()
87 .unwrap_or_else(|| VerifiedDigest::compute(DigestAlgorithm::Sha256, &bytes));
88 let cache_path = self.persist(locator, &bytes, &digest)?;
89 Ok(StoreArtifact {
90 locator: locator.clone(),
91 path: cache_path,
92 bytes,
93 verification: VerificationReport {
94 digest: Some(digest),
95 signature: report.signature,
96 },
97 })
98 }
99
100 fn try_fetch_cached(
101 &self,
102 locator: &StoreLocator,
103 policy: &VerificationPolicy,
104 ) -> Result<Option<StoreArtifact>, StoreError> {
105 let cache_key = self.compute_cache_key(locator);
106 let cache_path = self.cache_root.join(format!("{cache_key}.wasm"));
107 if !cache_path.exists() {
108 return Ok(None);
109 }
110
111 let bytes = std_fs::read(&cache_path)?;
112 let report = policy.verify(&bytes)?;
113 let digest = report
114 .digest
115 .clone()
116 .unwrap_or_else(|| VerifiedDigest::compute(DigestAlgorithm::Sha256, &bytes));
117 let digest_path = self.persist(locator, &bytes, &digest)?;
118 Ok(Some(StoreArtifact {
119 locator: locator.clone(),
120 path: digest_path,
121 bytes,
122 verification: VerificationReport {
123 digest: Some(digest),
124 signature: report.signature,
125 },
126 }))
127 }
128
129 fn fetch_bytes(&self, locator: &StoreLocator) -> Result<Vec<u8>, StoreError> {
130 match locator {
131 StoreLocator::Fs { path, .. } => crate::fs::fetch(path),
132 StoreLocator::Http(url) => {
133 #[cfg(feature = "http")]
134 {
135 http::fetch(&self.http_client, url)
136 }
137 #[cfg(not(feature = "http"))]
138 {
139 let _ = url;
140 Err(StoreError::UnsupportedScheme("http".into()))
141 }
142 }
143 StoreLocator::Https(url) => {
144 #[cfg(feature = "http")]
145 {
146 http::fetch(&self.http_client, url)
147 }
148 #[cfg(not(feature = "http"))]
149 {
150 let _ = url;
151 Err(StoreError::UnsupportedScheme("https".into()))
152 }
153 }
154 StoreLocator::Oci(reference) => oci::fetch(reference),
155 StoreLocator::Warg(reference) => warg::fetch(reference),
156 }
157 }
158
159 fn persist(
160 &self,
161 locator: &StoreLocator,
162 bytes: &[u8],
163 digest: &VerifiedDigest,
164 ) -> Result<PathBuf, StoreError> {
165 let mut file_name = digest.value.clone();
166 file_name.push_str(".wasm");
167 let path = self.cache_root.join(&file_name);
168 std_fs::write(&path, bytes)?;
169
170 let locator_cache = self
171 .cache_root
172 .join(format!("{}.wasm", self.compute_cache_key(locator)));
173 if locator_cache != path
174 && let Err(err) = std_fs::write(&locator_cache, bytes)
175 {
176 debug!(
177 "failed to update locator cache at {}: {}",
178 locator_cache.display(),
179 err
180 );
181 }
182
183 debug!("cached artifact {:?} at {}", locator, path.display());
184 Ok(path)
185 }
186}
187
188fn default_cache_dir() -> PathBuf {
189 std::env::temp_dir().join("greentic-component-cache")
190}
191
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub enum StoreLocator {
194 Fs { path: PathBuf, locator: String },
195 Http(Url),
196 Https(Url),
197 Oci(String),
198 Warg(String),
199}
200
201impl StoreLocator {
202 pub fn parse(raw: &str) -> Result<Self, StoreError> {
203 if raw.contains("://") {
204 let url = Url::parse(raw).map_err(|err| StoreError::InvalidLocator {
205 locator: raw.to_string(),
206 reason: err.to_string(),
207 })?;
208 match url.scheme() {
209 "fs" => {
210 let path = decode_fs_path(&url)?;
211 Ok(StoreLocator::Fs {
212 path,
213 locator: raw.to_string(),
214 })
215 }
216 "file" => {
217 let path = url.to_file_path().map_err(|_| StoreError::InvalidLocator {
218 locator: raw.to_string(),
219 reason: "unable to convert file URL to path".into(),
220 })?;
221 Ok(StoreLocator::Fs {
222 path,
223 locator: raw.to_string(),
224 })
225 }
226 "http" => Ok(StoreLocator::Http(url)),
227 "https" => Ok(StoreLocator::Https(url)),
228 "oci" => Ok(StoreLocator::Oci(url.to_string())),
229 "warg" => Ok(StoreLocator::Warg(url.to_string())),
230 other => Err(StoreError::UnsupportedScheme(other.to_string())),
231 }
232 } else {
233 let path = PathBuf::from(raw);
234 let path = canonicalize_or(path);
235 Ok(StoreLocator::Fs {
236 path,
237 locator: raw.to_string(),
238 })
239 }
240 }
241
242 pub fn as_cache_key(&self) -> String {
243 match self {
244 StoreLocator::Fs { locator, .. } => locator.clone(),
245 StoreLocator::Http(url) | StoreLocator::Https(url) => url.as_str().to_string(),
246 StoreLocator::Oci(reference) | StoreLocator::Warg(reference) => reference.clone(),
247 }
248 }
249}
250
251#[derive(Debug)]
252pub struct StoreArtifact {
253 pub locator: StoreLocator,
254 pub path: PathBuf,
255 pub bytes: Vec<u8>,
256 pub verification: VerificationReport,
257}
258
259#[derive(Debug, Error)]
260pub enum StoreError {
261 #[error("invalid locator `{locator}`: {reason}")]
262 InvalidLocator { locator: String, reason: String },
263 #[error("unsupported locator scheme `{0}`")]
264 UnsupportedScheme(String),
265 #[error(transparent)]
266 Io(#[from] std::io::Error),
267 #[cfg(feature = "http")]
268 #[error(transparent)]
269 Http(#[from] reqwest::Error),
270 #[error(transparent)]
271 Verification(#[from] VerificationError),
272}
273
274fn decode_fs_path(url: &Url) -> Result<PathBuf, StoreError> {
275 let mut path = String::new();
276 if let Some(host) = url.host_str().filter(|host| !host.is_empty()) {
277 path.push_str("//");
278 path.push_str(host);
279 }
280 path.push_str(url.path());
281 let decoded = percent_decode_str(&path)
282 .decode_utf8()
283 .map_err(|err: std::str::Utf8Error| StoreError::InvalidLocator {
284 locator: url.to_string(),
285 reason: err.to_string(),
286 })?;
287 let buf = PathBuf::from(decoded.as_ref());
288 Ok(canonicalize_or(buf))
289}
290
291fn canonicalize_or(path: PathBuf) -> PathBuf {
292 std_fs::canonicalize(&path).unwrap_or(path)
293}
294
295fn hash_locator(locator: &StoreLocator) -> String {
296 let mut hasher = Sha256::new();
297 hasher.update(locator.as_cache_key().as_bytes());
298 hex::encode(hasher.finalize())
299}
300
301impl ComponentStore {
302 fn compute_cache_key(&self, locator: &StoreLocator) -> String {
303 hash_locator(locator)
304 }
305}