Skip to main content

weft_core/app/
packages.rs

1use anyhow::{Context, Result};
2use async_trait::async_trait;
3use serde::de::DeserializeOwned;
4use serde::{Deserialize, Serialize};
5use std::path::Path;
6
7#[derive(Debug, Clone, Serialize, Deserialize, Default)]
8pub struct PackageSource {
9    pub name: String,
10    #[serde(default)]
11    pub kind: String,
12    #[serde(default)]
13    pub package_kind: String,
14    #[serde(default)]
15    pub runtime_provider: String,
16    #[serde(default)]
17    pub current_source: String,
18    #[serde(default)]
19    pub trusted: bool,
20    #[serde(default)]
21    pub signature: String,
22    #[serde(default)]
23    pub source_authority: String,
24    #[serde(default)]
25    pub source_public_keys: Vec<String>,
26    #[serde(default)]
27    pub provides: Vec<String>,
28    #[serde(default)]
29    pub requires: Vec<String>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33struct PackageSourceDto {
34    pub name: String,
35    #[serde(default)]
36    pub kind: String,
37    #[serde(default)]
38    pub package_kind: String,
39    #[serde(default)]
40    pub runtime_provider: String,
41    #[serde(default)]
42    pub current_source: String,
43    #[serde(default)]
44    pub trusted: bool,
45    #[serde(default)]
46    pub signature: String,
47    #[serde(default)]
48    pub source_authority: String,
49    #[serde(default)]
50    pub source_public_keys: Vec<String>,
51    #[serde(default)]
52    pub provides: Vec<String>,
53    #[serde(default)]
54    pub requires: Vec<String>,
55}
56
57impl From<PackageSourceDto> for PackageSource {
58    fn from(dto: PackageSourceDto) -> Self {
59        Self {
60            name: dto.name,
61            kind: dto.kind,
62            package_kind: dto.package_kind,
63            runtime_provider: dto.runtime_provider,
64            current_source: dto.current_source,
65            trusted: dto.trusted,
66            signature: dto.signature,
67            source_authority: dto.source_authority,
68            source_public_keys: dto.source_public_keys,
69            provides: dto.provides,
70            requires: dto.requires,
71        }
72    }
73}
74
75impl PackageSource {
76    pub fn runtime_provider_name(&self) -> String {
77        if !self.runtime_provider.trim().is_empty() {
78            return self.runtime_provider.trim().to_string();
79        }
80
81        std::path::Path::new(&self.current_source)
82            .file_name()
83            .and_then(|name| name.to_str())
84            .map(|name| name.to_string())
85            .filter(|name| !name.trim().is_empty())
86            .unwrap_or_else(|| self.name.clone())
87    }
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize, Default)]
91pub struct PackageIndex {
92    #[serde(default)]
93    pub version: u32,
94    #[serde(default)]
95    pub revision: String,
96    #[serde(default)]
97    pub source_url: String,
98    #[serde(default)]
99    pub package_sources: Vec<PackageSource>,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
103struct PackageIndexDto {
104    #[serde(default)]
105    pub version: u32,
106    #[serde(default)]
107    pub revision: String,
108    #[serde(default)]
109    pub source_url: String,
110    #[serde(default)]
111    pub package_sources: Vec<PackageSourceDto>,
112}
113
114impl From<PackageIndexDto> for PackageIndex {
115    fn from(dto: PackageIndexDto) -> Self {
116        Self {
117            version: dto.version,
118            revision: dto.revision,
119            source_url: dto.source_url,
120            package_sources: dto
121                .package_sources
122                .into_iter()
123                .map(PackageSource::from)
124                .collect(),
125        }
126    }
127}
128
129#[async_trait]
130pub trait PackageServiceClient: Send + Sync {
131    async fn fetch_package_index(&self, source_url: &str) -> Result<PackageIndex>;
132}
133
134#[derive(Default, Debug, Clone)]
135pub struct ReqwestPackageServiceClient {
136    http_client: reqwest::Client,
137}
138
139impl ReqwestPackageServiceClient {
140    pub fn new() -> Self {
141        Self {
142            http_client: reqwest::Client::new(),
143        }
144    }
145}
146
147#[async_trait]
148impl PackageServiceClient for ReqwestPackageServiceClient {
149    async fn fetch_package_index(&self, source_url: &str) -> Result<PackageIndex> {
150        let response = self
151            .http_client
152            .get(source_url)
153            .send()
154            .await?
155            .error_for_status()?;
156        let text = response.text().await?;
157        parse_package_index_response(&text)
158            .with_context(|| format!("Failed to parse package index response from {}", source_url))
159    }
160}
161
162impl PackageIndex {
163    pub fn get(&self, name: &str) -> Option<&PackageSource> {
164        self.package_sources.iter().find(|p| {
165            p.name == name || (!p.runtime_provider.trim().is_empty() && p.runtime_provider == name)
166        })
167    }
168
169    pub fn names(&self) -> Vec<&str> {
170        self.package_sources
171            .iter()
172            .map(|p| p.name.as_str())
173            .collect()
174    }
175
176    /// B3: capability-level requirement check. Returns every `requires` entry
177    /// declared by a package source for which no source in the index `provides`
178    /// the capability. An empty result means every declared requirement has at
179    /// least one provider registered (it does not guarantee that provider is
180    /// loadable — that is the runtime/validate concern).
181    ///
182    /// WEFT's dependency model is capability-based (`requires = ["session.events"]`),
183    /// not package-version-based, so this is the meaningful integrity check.
184    pub fn unmet_requirements(&self) -> Vec<UnmetRequirement> {
185        let provided: std::collections::HashSet<&str> = self
186            .package_sources
187            .iter()
188            .flat_map(|p| p.provides.iter().map(|s| s.as_str()))
189            .collect();
190
191        let mut unmet = Vec::new();
192        for source in &self.package_sources {
193            for capability in &source.requires {
194                let cap = capability.trim();
195                if cap.is_empty() {
196                    continue;
197                }
198                if !provided.contains(cap) {
199                    unmet.push(UnmetRequirement {
200                        package: source.name.clone(),
201                        capability: cap.to_string(),
202                    });
203                }
204            }
205        }
206        unmet
207    }
208}
209
210/// A capability a package requires that no registered source provides (B3).
211#[derive(Debug, Clone, PartialEq, Eq)]
212pub struct UnmetRequirement {
213    pub package: String,
214    pub capability: String,
215}
216
217pub fn load_package_index(packages_dir: &Path) -> Result<PackageIndex> {
218    let path = packages_dir.join("index.toml");
219    let content = std::fs::read_to_string(&path)
220        .with_context(|| format!("Failed to read {}", path.display()))?;
221    toml::from_str(&content).with_context(|| format!("Failed to parse {}", path.display()))
222}
223
224pub async fn fetch_package_index_from_url(source_url: &str) -> Result<PackageIndex> {
225    ReqwestPackageServiceClient::new()
226        .fetch_package_index(source_url)
227        .await
228}
229
230fn parse_package_index_response(text: &str) -> Result<PackageIndex> {
231    let dto: PackageIndexDto = serde_json::from_str(text)?;
232    Ok(dto.into())
233}
234
235fn load_cached_json<T>(path: &Path) -> Option<T>
236where
237    T: DeserializeOwned,
238{
239    std::fs::read_to_string(path)
240        .ok()
241        .and_then(|content| serde_json::from_str(&content).ok())
242}
243
244fn save_cached_json<T>(path: &Path, value: &T)
245where
246    T: serde::Serialize,
247{
248    let Some(parent) = path.parent() else {
249        return;
250    };
251
252    if let Err(error) = std::fs::create_dir_all(parent) {
253        tracing::warn!(
254            "Failed to create cache directory '{}': {}",
255            parent.display(),
256            error
257        );
258        return;
259    }
260
261    match serde_json::to_string_pretty(value) {
262        Ok(content) => {
263            if let Err(error) = std::fs::write(path, content) {
264                tracing::warn!("Failed to write cache file '{}': {}", path.display(), error);
265            }
266        }
267        Err(error) => {
268            tracing::warn!(
269                "Failed to serialize cache file '{}': {}",
270                path.display(),
271                error
272            );
273        }
274    }
275}
276
277pub async fn resolve_package_index(
278    repo_root: &Path,
279    data_dir: &str,
280    configured_source_url: Option<&str>,
281) -> PackageIndex {
282    resolve_package_index_with_fallback(repo_root, data_dir, configured_source_url).await
283}
284
285pub async fn resolve_package_index_with_fallback(
286    repo_root: &Path,
287    data_dir: &str,
288    configured_source_url: Option<&str>,
289) -> PackageIndex {
290    let client = ReqwestPackageServiceClient::new();
291    resolve_package_index_with_client(repo_root, data_dir, configured_source_url, &client).await
292}
293
294pub async fn resolve_package_index_with_client<C: PackageServiceClient + ?Sized>(
295    repo_root: &Path,
296    data_dir: &str,
297    configured_source_url: Option<&str>,
298    package_service_client: &C,
299) -> PackageIndex {
300    let packages_dir = repo_root.join("packages");
301    let package_cache_path = repo_root
302        .join(data_dir)
303        .join("source-cache")
304        .join("package-index.json");
305
306    let local_index = load_package_index(&packages_dir).unwrap_or_else(|e| {
307        tracing::warn!("Failed to load package index: {}", e);
308        PackageIndex::default()
309    });
310
311    let source_url = configured_source_url
312        .map(str::to_string)
313        .unwrap_or_else(|| local_index.source_url.clone());
314
315    if source_url.starts_with("http://") || source_url.starts_with("https://") {
316        match package_service_client
317            .fetch_package_index(&source_url)
318            .await
319        {
320            Ok(remote_index) => {
321                save_cached_json(&package_cache_path, &remote_index);
322                remote_index
323            }
324            Err(error) => {
325                if let Some(cached_index) = load_cached_json::<PackageIndex>(&package_cache_path) {
326                    tracing::warn!(
327                        "Failed to fetch package index from '{}': {}. Falling back to cached snapshot at '{}'.",
328                        source_url,
329                        error,
330                        package_cache_path.display()
331                    );
332                    cached_index
333                } else {
334                    tracing::warn!(
335                        "Failed to fetch package index from '{}': {}. Falling back to local index.",
336                        source_url,
337                        error
338                    );
339                    local_index
340                }
341            }
342        }
343    } else {
344        local_index
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::{
351        fetch_package_index_from_url, load_package_index, parse_package_index_response,
352        PackageIndex, PackageSource,
353    };
354    use crate::app::resolve_package_index;
355    use axum::routing::post;
356    use axum::{routing::get, Json, Router};
357    use serde_json::json;
358    use tokio::net::TcpListener;
359
360    fn src(name: &str, provides: &[&str], requires: &[&str]) -> PackageSource {
361        PackageSource {
362            name: name.to_string(),
363            provides: provides.iter().map(|s| s.to_string()).collect(),
364            requires: requires.iter().map(|s| s.to_string()).collect(),
365            ..Default::default()
366        }
367    }
368
369    #[test]
370    fn unmet_requirements_empty_when_all_satisfied() {
371        let index = PackageIndex {
372            package_sources: vec![
373                src("a", &["session.events"], &[]),
374                src("b", &["agent.runtime"], &["session.events"]),
375            ],
376            ..Default::default()
377        };
378        assert!(index.unmet_requirements().is_empty());
379    }
380
381    #[test]
382    fn unmet_requirements_flags_missing_provider() {
383        let index = PackageIndex {
384            package_sources: vec![
385                // requires ext.mcp but nothing provides it
386                src("weft-claw", &["weft_claw.turn"], &["ext.mcp", "agent.runtime"]),
387                src("agent", &["agent.runtime"], &[]),
388            ],
389            ..Default::default()
390        };
391        let unmet = index.unmet_requirements();
392        assert_eq!(unmet.len(), 1);
393        assert_eq!(unmet[0].package, "weft-claw");
394        assert_eq!(unmet[0].capability, "ext.mcp");
395    }
396
397    #[test]
398    fn unmet_requirements_repo_index_is_satisfied() {
399        // The shipped packages/index.toml must have no unmet capability requirements.
400        let repo_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..");
401        let index = load_package_index(&repo_root.join("packages")).expect("index loads");
402        let unmet = index.unmet_requirements();
403        assert!(
404            unmet.is_empty(),
405            "shipped index has unmet requirements: {unmet:?}"
406        );
407    }
408
409    const TRUSTED_INDEX_PACKAGES: &[&str] = &[
410        "agent-runtime",
411        "memory-store",
412        "skills-runtime",
413        "channel-core",
414        "cron",
415        "mcp-client",
416    ];
417    #[test]
418    fn repository_package_index_includes_current_trusted_entries() {
419        let repo_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..");
420        let index = load_package_index(&repo_root.join("packages")).expect("package index loads");
421
422        assert_eq!(index.revision, "packages-index-v1");
423
424        for package_name in TRUSTED_INDEX_PACKAGES {
425            let pkg = index
426                .get(package_name)
427                .unwrap_or_else(|| panic!("{package_name} package exists in index"));
428
429            assert!(pkg.trusted, "{package_name} should remain trusted");
430            assert!(
431                !pkg.current_source.trim().is_empty(),
432                "{package_name} should declare a current source"
433            );
434            assert!(
435                !pkg.signature.trim().is_empty(),
436                "{package_name} should declare a signature or builtin trust marker"
437            );
438            assert!(
439                !pkg.source_authority.trim().is_empty(),
440                "{package_name} should declare a source authority"
441            );
442        }
443    }
444
445    #[tokio::test]
446    async fn fetch_package_index_from_url_preserves_revision_metadata() {
447        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
448        let addr = listener.local_addr().unwrap();
449        let app = Router::new().route(
450            "/packages",
451            get(|| async {
452                Json(json!({
453                    "version": 1,
454                    "revision": "packages-remote-r7",
455                    "source_url": "https://registry.example/api/sources/packages",
456                    "package_sources": [
457                        {
458                            "name": "agent-runtime",
459                            "kind": "wasm",
460                            "current_source": "packages/official/agent-core",
461                            "trusted": true,
462                            "signature": "builtin:official",
463                            "source_authority": "official",
464                            "source_public_keys": []
465                        }
466                    ]
467                }))
468            }),
469        );
470        tokio::spawn(async move {
471            axum::serve(listener, app).await.unwrap();
472        });
473
474        let index = fetch_package_index_from_url(&format!("http://{addr}/packages"))
475            .await
476            .expect("remote package index loads");
477
478        assert_eq!(index.version, 1);
479        assert_eq!(index.revision, "packages-remote-r7");
480        assert_eq!(index.package_sources[0].source_authority, "official");
481    }
482
483    #[test]
484    fn parse_package_index_response_adapts_dto() {
485        let text = r#"
486            {
487              "version": 1,
488              "revision": "dto-v1",
489              "source_url": "https://registry.example/api/sources/packages",
490              "package_sources": [
491                {
492                  "name": "agent-runtime",
493                  "kind": "wasm",
494                  "current_source": "packages/official/agent-core",
495                  "trusted": true,
496                  "signature": "builtin:official",
497                  "source_authority": "official",
498                  "source_public_keys": []
499                }
500              ]
501            }
502        "#;
503
504        let index = parse_package_index_response(text).expect("dto parse");
505        assert_eq!(index.version, 1);
506        assert_eq!(index.revision, "dto-v1");
507        assert_eq!(
508            index.source_url,
509            "https://registry.example/api/sources/packages"
510        );
511
512        let pkg = &index.package_sources[0];
513        assert_eq!(pkg.name, "agent-runtime");
514        assert_eq!(pkg.kind, "wasm");
515        assert_eq!(pkg.current_source, "packages/official/agent-core");
516        assert_eq!(pkg.signature, "builtin:official");
517    }
518
519    fn write_package_index(path: &std::path::Path, index: &PackageIndex) {
520        let content = toml::to_string_pretty(index).expect("index serializes");
521        std::fs::write(path, content).expect("index written");
522    }
523
524    fn write_cached_index(path: &std::path::Path, index: &PackageIndex) {
525        let content = serde_json::to_string_pretty(index).expect("cache serializes");
526        std::fs::create_dir_all(path.parent().unwrap()).expect("cache parent exists");
527        std::fs::write(path, content).expect("cache written");
528    }
529
530    #[tokio::test]
531    async fn resolve_package_index_prefers_remote_and_caches_result() {
532        let root = tempfile::tempdir().expect("temp dir");
533        let packages_dir = root.path().join("packages");
534        std::fs::create_dir_all(&packages_dir).expect("packages dir");
535
536        let local_index = PackageIndex {
537            version: 1,
538            revision: "local-v1".into(),
539            source_url: String::new(),
540            package_sources: vec![],
541        };
542        write_package_index(&packages_dir.join("index.toml"), &local_index);
543
544        let remote_index = PackageIndex {
545            version: 2,
546            revision: "remote-v2".into(),
547            source_url: "https://registry.example/api/sources/packages".into(),
548            package_sources: vec![],
549        };
550
551        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
552        let addr = listener.local_addr().unwrap();
553        let app = Router::new().route(
554            "/packages",
555            get(move || {
556                let remote_index = remote_index.clone();
557                async move { Json(remote_index) }
558            }),
559        );
560        tokio::spawn(async move {
561            axum::serve(listener, app).await.unwrap();
562        });
563
564        let package_index = resolve_package_index(
565            root.path(),
566            "./data",
567            Some(&format!("http://{addr}/packages")),
568        )
569        .await;
570
571        assert_eq!(package_index.version, 2);
572        assert_eq!(package_index.revision, "remote-v2");
573
574        let cached_path = root.path().join("./data/source-cache/package-index.json");
575        let cached: PackageIndex =
576            serde_json::from_str(&std::fs::read_to_string(&cached_path).expect("cache exists"))
577                .expect("cache parse");
578        assert_eq!(cached.revision, "remote-v2");
579    }
580
581    #[tokio::test]
582    async fn resolve_package_index_falls_back_to_cached_when_remote_fails() {
583        let root = tempfile::tempdir().expect("temp dir");
584        let packages_dir = root.path().join("packages");
585        std::fs::create_dir_all(&packages_dir).expect("packages dir");
586
587        let local_index = PackageIndex {
588            version: 1,
589            revision: "local-v1".into(),
590            source_url: "https://registry.example/api/sources/packages".into(),
591            package_sources: vec![],
592        };
593        write_package_index(&packages_dir.join("index.toml"), &local_index);
594
595        let cached_index = PackageIndex {
596            version: 3,
597            revision: "cached-v3".into(),
598            source_url: String::new(),
599            package_sources: vec![],
600        };
601
602        let cache_path = root.path().join("./data/source-cache/package-index.json");
603        write_cached_index(&cache_path, &cached_index);
604
605        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
606        let addr = listener.local_addr().unwrap();
607        let app = Router::new().route(
608            "/packages",
609            post(|| async { axum::http::StatusCode::INTERNAL_SERVER_ERROR }),
610        );
611        tokio::spawn(async move {
612            axum::serve(listener, app).await.unwrap();
613        });
614
615        let package_index = resolve_package_index(
616            root.path(),
617            "./data",
618            Some(&format!("http://{addr}/packages")),
619        )
620        .await;
621
622        assert_eq!(package_index.version, 3);
623        assert_eq!(package_index.revision, "cached-v3");
624    }
625}