1use std::path::PathBuf;
11use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
12
13use async_trait::async_trait;
14use chrono::{DateTime, Utc};
15use dashmap::DashMap;
16use serde::{Deserialize, Serialize};
17use tracing::{info, warn};
18
19#[cfg(feature = "exochain")]
20use std::sync::Arc;
21
22use crate::error::KernelError;
23use crate::health::HealthStatus;
24use crate::service::{ServiceType, SystemService};
25
26#[non_exhaustive]
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
33pub enum ArtifactType {
34 WasmModule,
36 AppManifest,
38 ConfigBundle,
40 Generic,
42}
43
44impl std::fmt::Display for ArtifactType {
45 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46 match self {
47 Self::WasmModule => write!(f, "wasm-module"),
48 Self::AppManifest => write!(f, "app-manifest"),
49 Self::ConfigBundle => write!(f, "config-bundle"),
50 Self::Generic => write!(f, "generic"),
51 }
52 }
53}
54
55pub struct StoredArtifact {
61 pub hash: String,
63 pub size: u64,
65 pub content_type: ArtifactType,
67 pub stored_at: DateTime<Utc>,
69 pub reference_count: AtomicU32,
71}
72
73#[non_exhaustive]
79pub enum ArtifactBackend {
80 Memory(DashMap<String, Vec<u8>>),
82 File {
84 base_path: PathBuf,
86 },
87}
88
89pub struct ArtifactStore {
100 artifacts: DashMap<String, StoredArtifact>,
102 backend: ArtifactBackend,
104 total_size: AtomicU64,
106 #[cfg(feature = "exochain")]
108 chain_manager: Option<Arc<crate::chain::ChainManager>>,
109}
110
111impl ArtifactStore {
112 pub fn new_memory() -> Self {
114 Self {
115 artifacts: DashMap::new(),
116 backend: ArtifactBackend::Memory(DashMap::new()),
117 total_size: AtomicU64::new(0),
118 #[cfg(feature = "exochain")]
119 chain_manager: None,
120 }
121 }
122
123 pub fn new_file(base_path: PathBuf) -> Self {
125 Self {
126 artifacts: DashMap::new(),
127 backend: ArtifactBackend::File { base_path },
128 total_size: AtomicU64::new(0),
129 #[cfg(feature = "exochain")]
130 chain_manager: None,
131 }
132 }
133
134 pub fn open_file(base_path: PathBuf) -> Result<Self, KernelError> {
139 let store = Self::new_file(base_path.clone());
140 let Ok(shards) = std::fs::read_dir(&base_path) else {
141 return Ok(store);
142 };
143 for shard in shards {
148 let shard = match shard {
149 Ok(s) => s,
150 Err(e) => {
151 warn!(error = %e, "artifact index: skipping an unreadable directory entry");
152 continue;
153 }
154 };
155 let prefix = shard.file_name().to_string_lossy().into_owned();
156 if prefix.len() != 2 || !shard.file_type().is_ok_and(|t| t.is_dir()) {
157 continue;
158 }
159 let entries = match std::fs::read_dir(shard.path()) {
160 Ok(e) => e,
161 Err(e) => {
162 warn!(shard = %prefix, error = %e, "artifact index: skipping an unreadable shard");
163 continue;
164 }
165 };
166 for entry in entries {
167 let entry = match entry {
168 Ok(e) => e,
169 Err(e) => {
170 warn!(shard = %prefix, error = %e, "artifact index: skipping an unreadable entry");
171 continue;
172 }
173 };
174 let name = entry.file_name().to_string_lossy().into_owned();
175 let hex = name.len() == 64
176 && name.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'));
177 let meta = match entry.metadata() {
178 Ok(m) => m,
179 Err(e) => {
180 warn!(entry = %name, error = %e, "artifact index: skipping an entry with no metadata");
181 continue;
182 }
183 };
184 if !hex || !name.starts_with(&prefix) || !meta.is_file() {
185 continue;
186 }
187 store.total_size.fetch_add(meta.len(), Ordering::Relaxed);
188 store.artifacts.insert(
189 name.clone(),
190 StoredArtifact {
191 hash: name,
192 size: meta.len(),
193 content_type: ArtifactType::Generic,
194 stored_at: Utc::now(),
195 reference_count: AtomicU32::new(1),
196 },
197 );
198 }
199 }
200 Ok(store)
201 }
202
203 #[cfg(feature = "exochain")]
205 pub fn set_chain_manager(&mut self, cm: Arc<crate::chain::ChainManager>) {
206 self.chain_manager = Some(cm);
207 }
208
209 pub fn store(&self, content: &[u8], content_type: ArtifactType) -> Result<String, KernelError> {
214 let hash = blake3::hash(content).to_hex().to_string();
215
216 if let Some(existing) = self.artifacts.get(&hash) {
218 existing.reference_count.fetch_add(1, Ordering::Relaxed);
219 return Ok(hash);
220 }
221
222 match &self.backend {
224 ArtifactBackend::Memory(map) => {
225 map.insert(hash.clone(), content.to_vec());
226 }
227 ArtifactBackend::File { base_path } => {
228 let prefix = &hash[..2.min(hash.len())];
229 let dir = base_path.join(prefix);
230 std::fs::create_dir_all(&dir)
231 .map_err(|e| KernelError::Service(format!("artifact dir create: {e}")))?;
232 std::fs::write(dir.join(&hash), content)
233 .map_err(|e| KernelError::Service(format!("artifact write: {e}")))?;
234 }
235 }
236
237 let size = content.len() as u64;
239 #[cfg(feature = "exochain")]
240 let content_type_str = content_type.to_string();
241 self.artifacts.insert(
242 hash.clone(),
243 StoredArtifact {
244 hash: hash.clone(),
245 size,
246 content_type,
247 stored_at: Utc::now(),
248 reference_count: AtomicU32::new(1),
249 },
250 );
251 self.total_size.fetch_add(size, Ordering::Relaxed);
252
253 #[cfg(feature = "exochain")]
254 if let Some(ref cm) = self.chain_manager {
255 cm.append(
256 "artifact_store",
257 crate::chain::EVENT_KIND_ARTIFACT_STORE,
258 Some(serde_json::json!({
259 "hash": hash,
260 "size": size,
261 "content_type": content_type_str,
262 })),
263 );
264 }
265
266 info!(hash = %hash, size, "artifact stored");
267 Ok(hash)
268 }
269
270 pub fn load(&self, hash: &str) -> Result<Vec<u8>, KernelError> {
272 if !self.artifacts.contains_key(hash) {
273 return Err(KernelError::Service(format!("artifact not found: {hash}")));
274 }
275
276 let content = match &self.backend {
277 ArtifactBackend::Memory(map) => map.get(hash).map(|v| v.value().clone()),
278 ArtifactBackend::File { base_path } => {
279 let prefix = &hash[..2.min(hash.len())];
280 let path = base_path.join(prefix).join(hash);
281 std::fs::read(&path).ok()
282 }
283 };
284
285 let content = content
286 .ok_or_else(|| KernelError::Service(format!("artifact data missing: {hash}")))?;
287
288 let actual_hash = blake3::hash(&content).to_hex().to_string();
290 if actual_hash != hash {
291 return Err(KernelError::Service(format!(
292 "artifact integrity error: expected {hash}, got {actual_hash}"
293 )));
294 }
295
296 Ok(content)
297 }
298
299 pub fn contains(&self, hash: &str) -> bool {
301 self.artifacts.contains_key(hash)
302 }
303
304 pub fn release(&self, hash: &str) -> bool {
308 if let Some(entry) = self.artifacts.get(hash) {
309 let prev = entry.reference_count.fetch_sub(1, Ordering::Relaxed);
310 return prev <= 1;
311 }
312 false
313 }
314
315 pub fn remove(&self, hash: &str) -> Result<(), KernelError> {
317 if let Some((_, meta)) = self.artifacts.remove(hash) {
318 self.total_size.fetch_sub(meta.size, Ordering::Relaxed);
319 match &self.backend {
320 ArtifactBackend::Memory(map) => {
321 map.remove(hash);
322 }
323 ArtifactBackend::File { base_path } => {
324 let prefix = &hash[..2.min(hash.len())];
325 let path = base_path.join(prefix).join(hash);
326 let _ = std::fs::remove_file(path);
327 }
328 }
329
330 #[cfg(feature = "exochain")]
331 if let Some(ref cm) = self.chain_manager {
332 cm.append(
333 "artifact_store",
334 crate::chain::EVENT_KIND_ARTIFACT_REMOVE,
335 Some(serde_json::json!({
336 "hash": hash,
337 })),
338 );
339 }
340 }
341 Ok(())
342 }
343
344 pub fn count(&self) -> usize {
346 self.artifacts.len()
347 }
348
349 pub fn total_bytes(&self) -> u64 {
351 self.total_size.load(Ordering::Relaxed)
352 }
353
354 pub fn metadata(&self, hash: &str) -> Option<(String, u64, ArtifactType, DateTime<Utc>, u32)> {
356 self.artifacts.get(hash).map(|a| {
357 (
358 a.hash.clone(),
359 a.size,
360 a.content_type.clone(),
361 a.stored_at,
362 a.reference_count.load(Ordering::Relaxed),
363 )
364 })
365 }
366}
367
368#[async_trait]
369impl SystemService for ArtifactStore {
370 fn name(&self) -> &str {
371 "artifact-store"
372 }
373
374 fn service_type(&self) -> ServiceType {
375 ServiceType::Core
376 }
377
378 async fn start(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
379 info!("artifact store started");
380 Ok(())
381 }
382
383 async fn stop(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
384 info!(
385 count = self.count(),
386 total_bytes = self.total_bytes(),
387 "artifact store stopped"
388 );
389 Ok(())
390 }
391
392 async fn health_check(&self) -> HealthStatus {
393 HealthStatus::Healthy
394 }
395}
396
397#[cfg(test)]
400mod tests {
401 use super::*;
402
403 #[cfg(unix)]
404 #[test]
405 fn open_file_skips_an_unreadable_shard_instead_of_failing() {
406 use std::os::unix::fs::PermissionsExt;
407 let dir = tempfile::tempdir().unwrap();
408 let writer = ArtifactStore::new_file(dir.path().to_path_buf());
409 let kept = writer.store(b"readable blob", ArtifactType::Generic).unwrap();
410 let hidden = writer.store(b"blob in a locked shard", ArtifactType::Generic).unwrap();
411 let locked = dir.path().join(&hidden[..2]);
412 std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
413 let readable = std::fs::read_dir(&locked).is_ok();
415 let opened = ArtifactStore::open_file(dir.path().to_path_buf());
416 std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap();
417 let store = opened.expect("an unreadable shard is skipped, not fatal");
418 if !readable && kept[..2] != hidden[..2] {
419 assert!(store.contains(&kept), "the readable shard is still indexed");
420 assert!(!store.contains(&hidden), "the locked one is not");
421 }
422 }
423
424 #[test]
425 fn store_and_load_roundtrip() {
426 let store = ArtifactStore::new_memory();
427 let content = b"hello weftos artifact";
428 let hash = store.store(content, ArtifactType::Generic).unwrap();
429 let loaded = store.load(&hash).unwrap();
430 assert_eq!(loaded, content);
431 }
432
433 #[test]
434 fn hash_verification_on_load() {
435 let store = ArtifactStore::new_memory();
436 let content = b"original content";
437 let hash = store.store(content, ArtifactType::WasmModule).unwrap();
438
439 if let ArtifactBackend::Memory(map) = &store.backend {
441 map.insert(hash.clone(), b"tampered content".to_vec());
442 }
443
444 let result = store.load(&hash);
445 assert!(result.is_err());
446 let err = result.unwrap_err().to_string();
447 assert!(err.contains("integrity error"), "got: {err}");
448 }
449
450 #[test]
451 fn duplicate_store_returns_same_hash() {
452 let store = ArtifactStore::new_memory();
453 let content = b"dedup test";
454 let h1 = store.store(content, ArtifactType::Generic).unwrap();
455 let h2 = store.store(content, ArtifactType::Generic).unwrap();
456 assert_eq!(h1, h2);
457 assert_eq!(store.count(), 1);
458 }
459
460 #[test]
461 fn duplicate_store_increments_refcount() {
462 let store = ArtifactStore::new_memory();
463 let content = b"refcount test";
464 let hash = store.store(content, ArtifactType::Generic).unwrap();
465 let _ = store.store(content, ArtifactType::Generic).unwrap();
466 let (_, _, _, _, refcount) = store.metadata(&hash).unwrap();
467 assert_eq!(refcount, 2);
468 }
469
470 #[test]
471 fn reference_counting_release() {
472 let store = ArtifactStore::new_memory();
473 let content = b"rc data";
474 let hash = store.store(content, ArtifactType::Generic).unwrap();
475 let _ = store.store(content, ArtifactType::Generic).unwrap();
476
477 assert!(!store.release(&hash)); assert!(store.release(&hash)); }
480
481 #[test]
482 fn load_nonexistent_fails() {
483 let store = ArtifactStore::new_memory();
484 let result = store.load("0000000000000000000000000000000000000000000000000000000000000000");
485 assert!(result.is_err());
486 }
487
488 #[test]
489 fn total_bytes_tracked() {
490 let store = ArtifactStore::new_memory();
491 store.store(b"aaaa", ArtifactType::Generic).unwrap();
492 store.store(b"bbb", ArtifactType::Generic).unwrap();
493 assert_eq!(store.total_bytes(), 7);
494 }
495
496 #[test]
497 fn remove_artifact() {
498 let store = ArtifactStore::new_memory();
499 let hash = store.store(b"remove me", ArtifactType::Generic).unwrap();
500 assert!(store.contains(&hash));
501 store.remove(&hash).unwrap();
502 assert!(!store.contains(&hash));
503 assert_eq!(store.count(), 0);
504 }
505
506 #[test]
507 fn file_backend_roundtrip() {
508 let dir = std::env::temp_dir().join(format!("artifact_test_{}", uuid::Uuid::new_v4()));
509 std::fs::create_dir_all(&dir).unwrap();
510
511 let store = ArtifactStore::new_file(dir.clone());
512 let content = b"file backend content";
513 let hash = store.store(content, ArtifactType::AppManifest).unwrap();
514
515 let prefix = &hash[..2];
517 let file_path = dir.join(prefix).join(&hash);
518 assert!(file_path.exists(), "expected file at {file_path:?}");
519
520 let loaded = store.load(&hash).unwrap();
521 assert_eq!(loaded, content);
522
523 let _ = std::fs::remove_dir_all(&dir);
525 }
526
527 #[test]
528 fn different_content_different_hash() {
529 let store = ArtifactStore::new_memory();
530 let h1 = store.store(b"alpha", ArtifactType::Generic).unwrap();
531 let h2 = store.store(b"beta", ArtifactType::Generic).unwrap();
532 assert_ne!(h1, h2);
533 assert_eq!(store.count(), 2);
534 }
535
536 #[tokio::test]
537 async fn system_service_impl() {
538 let store = ArtifactStore::new_memory();
539 assert_eq!(store.name(), "artifact-store");
540 assert_eq!(store.service_type(), ServiceType::Core);
541 store.start().await.unwrap();
542 let health = store.health_check().await;
543 assert_eq!(health, HealthStatus::Healthy);
544 store.stop().await.unwrap();
545 }
546}