Architecture review (all P0+P1 issues now fixed): - Add 10s timeout to 6 bare Nostr client.connect() calls - Pin all 12 crypto deps to exact versions from Cargo.lock - Pin all 15 floating container image tags to exact patch versions - Add CI pipeline (cargo fmt + clippy + tests, frontend type-check + build) Self-update system (git.tx1138.com): - scripts/self-update.sh: pull, build, install, restart with rollback - systemd timer checks daily at 3 AM - update.check RPC does git-based checks when repo is present - update.git-apply RPC triggers self-update from UI - Default update URL changed from GitHub to git.tx1138.com - Git added to ISO package list for fresh installs Documentation: - CHANGELOG v1.3.1 with all changes - README updated (version, update system section) - BETA-PROGRESS session #6 logged - architecture-review.html: 4 issues marked FIXED, 8/12 refactoring done Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
655 lines
21 KiB
Rust
655 lines
21 KiB
Rust
//! Decentralized app marketplace: discover, verify, and publish app manifests
|
|
//! via Nostr relays. Uses NIP-78 (kind 30078) with d-tag "archipelago-app:<id>".
|
|
//!
|
|
//! See docs/marketplace-protocol.md for the full protocol specification.
|
|
|
|
use anyhow::{Context, Result};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
use std::path::Path;
|
|
use std::time::Duration;
|
|
use tokio::fs;
|
|
use tracing::{debug, info, warn};
|
|
|
|
const MARKETPLACE_DIR: &str = "marketplace";
|
|
const CACHE_FILE: &str = "manifests.json";
|
|
const PUBLISHED_DIR: &str = "published";
|
|
const ARCHIPELAGO_KIND: u64 = 30078;
|
|
const D_TAG_PREFIX: &str = "archipelago-app:";
|
|
const MARKETPLACE_TAG: &str = "archipelago-marketplace";
|
|
|
|
/// Categories for marketplace apps.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum AppCategory {
|
|
Money,
|
|
Commerce,
|
|
Data,
|
|
Networking,
|
|
Home,
|
|
Community,
|
|
Other,
|
|
}
|
|
|
|
impl std::fmt::Display for AppCategory {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::Money => write!(f, "money"),
|
|
Self::Commerce => write!(f, "commerce"),
|
|
Self::Data => write!(f, "data"),
|
|
Self::Networking => write!(f, "networking"),
|
|
Self::Home => write!(f, "home"),
|
|
Self::Community => write!(f, "community"),
|
|
Self::Other => write!(f, "other"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Author information in a marketplace manifest.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ManifestAuthor {
|
|
pub name: String,
|
|
pub did: String,
|
|
#[serde(default)]
|
|
pub nostr_pubkey: String,
|
|
}
|
|
|
|
/// Container configuration in a marketplace manifest.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ManifestContainer {
|
|
pub image: String,
|
|
#[serde(default)]
|
|
pub ports: Vec<PortMapping>,
|
|
#[serde(default)]
|
|
pub volumes: Vec<VolumeMapping>,
|
|
#[serde(default)]
|
|
pub env: HashMap<String, String>,
|
|
#[serde(default)]
|
|
pub capabilities: Vec<String>,
|
|
#[serde(default = "default_true")]
|
|
pub readonly_root: bool,
|
|
#[serde(default = "default_true")]
|
|
pub no_new_privileges: bool,
|
|
#[serde(default = "default_uid")]
|
|
pub run_as_user: u32,
|
|
}
|
|
|
|
fn default_true() -> bool {
|
|
true
|
|
}
|
|
|
|
fn default_uid() -> u32 {
|
|
1000
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PortMapping {
|
|
pub container: u16,
|
|
pub host: u16,
|
|
#[serde(default = "default_tcp")]
|
|
pub protocol: String,
|
|
}
|
|
|
|
fn default_tcp() -> String {
|
|
"tcp".into()
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct VolumeMapping {
|
|
pub name: String,
|
|
pub path: String,
|
|
}
|
|
|
|
/// App manifest signatures.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ManifestSignatures {
|
|
pub manifest_hash: String,
|
|
pub did_signature: String,
|
|
}
|
|
|
|
/// A marketplace app manifest.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct AppManifest {
|
|
pub app_id: String,
|
|
pub name: String,
|
|
pub version: String,
|
|
pub description: ManifestDescription,
|
|
pub author: ManifestAuthor,
|
|
pub container: ManifestContainer,
|
|
pub category: AppCategory,
|
|
#[serde(default)]
|
|
pub icon_url: String,
|
|
#[serde(default)]
|
|
pub repo_url: String,
|
|
#[serde(default)]
|
|
pub license: String,
|
|
#[serde(default)]
|
|
pub min_archipelago_version: String,
|
|
#[serde(default)]
|
|
pub dependencies: Vec<String>,
|
|
#[serde(default)]
|
|
pub signatures: Option<ManifestSignatures>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(untagged)]
|
|
pub enum ManifestDescription {
|
|
Simple(String),
|
|
Detailed { short: String, long: String },
|
|
}
|
|
|
|
|
|
/// A discovered marketplace app with trust scoring.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct DiscoveredApp {
|
|
pub manifest: AppManifest,
|
|
pub trust_score: u32,
|
|
pub trust_tier: String,
|
|
pub relay_count: u32,
|
|
pub first_seen: String,
|
|
pub nostr_pubkey: String,
|
|
}
|
|
|
|
/// Cache of discovered marketplace apps.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
pub struct MarketplaceCache {
|
|
pub apps: Vec<DiscoveredApp>,
|
|
pub last_updated: String,
|
|
}
|
|
|
|
/// Ensure marketplace directories exist.
|
|
async fn ensure_dirs(data_dir: &Path) -> Result<()> {
|
|
let market_dir = data_dir.join(MARKETPLACE_DIR);
|
|
fs::create_dir_all(market_dir.join("cache")).await.context("Creating marketplace cache dir")?;
|
|
fs::create_dir_all(market_dir.join(PUBLISHED_DIR)).await.context("Creating marketplace published dir")?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Load cached marketplace data.
|
|
pub async fn load_cache(data_dir: &Path) -> Result<MarketplaceCache> {
|
|
let path = data_dir.join(MARKETPLACE_DIR).join("cache").join(CACHE_FILE);
|
|
if !path.exists() {
|
|
return Ok(MarketplaceCache::default());
|
|
}
|
|
let data = fs::read_to_string(&path).await.context("Reading marketplace cache")?;
|
|
serde_json::from_str(&data).context("Parsing marketplace cache")
|
|
}
|
|
|
|
/// Save marketplace cache.
|
|
pub async fn save_cache(data_dir: &Path, cache: &MarketplaceCache) -> Result<()> {
|
|
ensure_dirs(data_dir).await?;
|
|
let path = data_dir.join(MARKETPLACE_DIR).join("cache").join(CACHE_FILE);
|
|
let data = serde_json::to_string_pretty(cache)?;
|
|
fs::write(&path, data).await.context("Writing marketplace cache")?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Validate a manifest meets security requirements.
|
|
pub fn validate_manifest(manifest: &AppManifest) -> Vec<String> {
|
|
let mut issues = Vec::new();
|
|
|
|
// Required fields
|
|
if manifest.app_id.is_empty() {
|
|
issues.push("Missing app_id".into());
|
|
}
|
|
if manifest.name.is_empty() {
|
|
issues.push("Missing name".into());
|
|
}
|
|
if manifest.version.is_empty() {
|
|
issues.push("Missing version".into());
|
|
}
|
|
if manifest.container.image.is_empty() {
|
|
issues.push("Missing container image".into());
|
|
}
|
|
|
|
// Security checks
|
|
if manifest.container.image.ends_with(":latest") {
|
|
issues.push("Container image uses :latest tag (must pin specific version)".into());
|
|
}
|
|
if !manifest.container.readonly_root {
|
|
issues.push("readonly_root is false (should be true)".into());
|
|
}
|
|
if !manifest.container.no_new_privileges {
|
|
issues.push("no_new_privileges is false (should be true)".into());
|
|
}
|
|
if manifest.container.run_as_user < 1000 {
|
|
issues.push(format!("run_as_user is {} (must be >= 1000)", manifest.container.run_as_user));
|
|
}
|
|
|
|
// app_id format
|
|
if !manifest.app_id.chars().all(|c| c.is_ascii_lowercase() || c == '-' || c.is_ascii_digit()) {
|
|
issues.push("app_id must be lowercase kebab-case".into());
|
|
}
|
|
|
|
issues
|
|
}
|
|
|
|
/// Calculate trust score for a discovered app manifest.
|
|
pub fn calculate_trust_score(
|
|
manifest: &AppManifest,
|
|
relay_count: u32,
|
|
federated_dids: &[String],
|
|
) -> (u32, String) {
|
|
let mut score: u32 = 0;
|
|
|
|
// DID verification (30 points) — has a valid DID in author
|
|
if !manifest.author.did.is_empty() && manifest.author.did.starts_with("did:") {
|
|
score += 30;
|
|
}
|
|
|
|
// Relay consensus (20 points) — found on multiple relays
|
|
score += match relay_count {
|
|
0..=1 => 5,
|
|
2..=3 => 12,
|
|
_ => 20,
|
|
};
|
|
|
|
// Federation trust (20 points) — developer DID in federation
|
|
if federated_dids.contains(&manifest.author.did) {
|
|
score += 20;
|
|
}
|
|
|
|
// Version history (15 points) — has a proper semver version
|
|
if manifest.version.split('.').count() == 3 {
|
|
score += 10;
|
|
}
|
|
if !manifest.repo_url.is_empty() {
|
|
score += 5;
|
|
}
|
|
|
|
// Security compliance (15 points)
|
|
let issues = validate_manifest(manifest);
|
|
if issues.is_empty() {
|
|
score += 15;
|
|
} else if issues.len() <= 2 {
|
|
score += 5;
|
|
}
|
|
|
|
let tier = match score {
|
|
80..=100 => "verified",
|
|
50..=79 => "community",
|
|
20..=49 => "unverified",
|
|
_ => "untrusted",
|
|
};
|
|
|
|
(score, tier.to_string())
|
|
}
|
|
|
|
/// Discover app manifests from Nostr relays.
|
|
///
|
|
/// Queries configured relays for kind 30078 events with the marketplace tag,
|
|
/// parses manifests, validates, scores, and returns sorted by trust score.
|
|
pub async fn discover(
|
|
data_dir: &Path,
|
|
relays: &[String],
|
|
tor_proxy: Option<&str>,
|
|
federated_dids: &[String],
|
|
) -> Result<Vec<DiscoveredApp>> {
|
|
if relays.is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
info!(relay_count = relays.len(), "Discovering marketplace apps from Nostr relays");
|
|
|
|
let anon_keys = nostr_sdk::prelude::Keys::generate();
|
|
let client = build_nostr_client(anon_keys, tor_proxy)?;
|
|
for url in relays {
|
|
let _ = client.add_relay(url).await;
|
|
}
|
|
if tokio::time::timeout(Duration::from_secs(10), client.connect()).await.is_err() {
|
|
tracing::warn!("Nostr relay connection timed out after 10s, continuing anyway");
|
|
}
|
|
|
|
let filter = nostr_sdk::prelude::Filter::new()
|
|
.kind(nostr_sdk::prelude::Kind::Custom(ARCHIPELAGO_KIND as u16))
|
|
.hashtag(MARKETPLACE_TAG)
|
|
.limit(200);
|
|
|
|
let events = client
|
|
.fetch_events(filter, std::time::Duration::from_secs(20))
|
|
.await
|
|
.map(|e| e.to_vec())
|
|
.unwrap_or_default();
|
|
client.disconnect().await;
|
|
|
|
debug!(event_count = events.len(), "Fetched marketplace events from relays");
|
|
|
|
// Deduplicate by app_id, keeping the latest version
|
|
let mut app_map: HashMap<String, (DiscoveredApp, u32)> = HashMap::new();
|
|
|
|
for event in events {
|
|
// Parse manifest from event content
|
|
let manifest: AppManifest = match serde_json::from_str(&event.content) {
|
|
Ok(m) => m,
|
|
Err(e) => {
|
|
debug!(err = %e, "Skipping invalid marketplace manifest");
|
|
continue;
|
|
}
|
|
};
|
|
|
|
// Validate
|
|
let issues = validate_manifest(&manifest);
|
|
if issues.iter().any(|i| i.contains("Missing app_id") || i.contains("Missing container image")) {
|
|
debug!(issues = ?issues, "Skipping manifest with critical issues");
|
|
continue;
|
|
}
|
|
|
|
let app_id = manifest.app_id.clone();
|
|
let entry = app_map.entry(app_id).or_insert_with(|| {
|
|
let (trust_score, trust_tier) = calculate_trust_score(&manifest, 1, federated_dids);
|
|
(
|
|
DiscoveredApp {
|
|
manifest,
|
|
trust_score,
|
|
trust_tier,
|
|
relay_count: 0,
|
|
first_seen: event.created_at.to_human_datetime(),
|
|
nostr_pubkey: event.pubkey.to_hex(),
|
|
},
|
|
0,
|
|
)
|
|
});
|
|
entry.1 += 1;
|
|
}
|
|
|
|
// Update relay counts and recalculate scores
|
|
let mut apps: Vec<DiscoveredApp> = app_map
|
|
.into_values()
|
|
.map(|(mut app, relay_count)| {
|
|
app.relay_count = relay_count;
|
|
let (score, tier) = calculate_trust_score(&app.manifest, relay_count, federated_dids);
|
|
app.trust_score = score;
|
|
app.trust_tier = tier;
|
|
app
|
|
})
|
|
.collect();
|
|
|
|
// Sort by trust score descending
|
|
apps.sort_by(|a, b| b.trust_score.cmp(&a.trust_score));
|
|
|
|
// Cache results
|
|
let cache = MarketplaceCache {
|
|
apps: apps.clone(),
|
|
last_updated: chrono::Utc::now().to_rfc3339(),
|
|
};
|
|
if let Err(e) = save_cache(data_dir, &cache).await {
|
|
warn!(err = %e, "Failed to save marketplace cache");
|
|
}
|
|
|
|
info!(app_count = apps.len(), "Marketplace discovery complete");
|
|
Ok(apps)
|
|
}
|
|
|
|
/// Publish an app manifest to Nostr relays.
|
|
pub async fn publish(
|
|
data_dir: &Path,
|
|
manifest: &AppManifest,
|
|
relays: &[String],
|
|
tor_proxy: Option<&str>,
|
|
) -> Result<String> {
|
|
if relays.is_empty() {
|
|
anyhow::bail!("No relays configured for publishing");
|
|
}
|
|
|
|
let issues = validate_manifest(manifest);
|
|
if !issues.is_empty() {
|
|
anyhow::bail!("Manifest validation failed: {}", issues.join(", "));
|
|
}
|
|
|
|
let identity_dir = data_dir.join("identity");
|
|
let keys = load_or_create_keys(&identity_dir).await?;
|
|
let client = build_nostr_client(keys, tor_proxy)?;
|
|
|
|
let content = serde_json::to_string(manifest).context("Serializing manifest")?;
|
|
let d_tag = format!("{}{}", D_TAG_PREFIX, manifest.app_id);
|
|
|
|
for url in relays {
|
|
let _ = client.add_relay(url).await;
|
|
}
|
|
if tokio::time::timeout(Duration::from_secs(10), client.connect()).await.is_err() {
|
|
tracing::warn!("Nostr relay connection timed out after 10s, continuing anyway");
|
|
}
|
|
|
|
let builder = nostr_sdk::prelude::EventBuilder::new(
|
|
nostr_sdk::prelude::Kind::Custom(ARCHIPELAGO_KIND as u16),
|
|
&content,
|
|
)
|
|
.tag(nostr_sdk::prelude::Tag::identifier(&d_tag))
|
|
.tag(nostr_sdk::prelude::Tag::hashtag(MARKETPLACE_TAG))
|
|
.tag(nostr_sdk::prelude::Tag::hashtag(format!("category:{}", manifest.category)))
|
|
.tag(nostr_sdk::prelude::Tag::custom(
|
|
nostr_sdk::prelude::TagKind::custom("version"),
|
|
[&manifest.version],
|
|
))
|
|
.tag(nostr_sdk::prelude::Tag::custom(
|
|
nostr_sdk::prelude::TagKind::custom("image"),
|
|
[&manifest.container.image],
|
|
));
|
|
|
|
let output = client.send_event_builder(builder).await?;
|
|
client.disconnect().await;
|
|
|
|
// Save to published directory
|
|
ensure_dirs(data_dir).await?;
|
|
let pub_path = data_dir
|
|
.join(MARKETPLACE_DIR)
|
|
.join(PUBLISHED_DIR)
|
|
.join(format!("{}.json", manifest.app_id));
|
|
let pub_data = serde_json::to_string_pretty(manifest)?;
|
|
fs::write(&pub_path, pub_data).await.context("Saving published manifest")?;
|
|
|
|
info!(app_id = %manifest.app_id, "Published app manifest to {} relays", relays.len());
|
|
Ok(output.id().to_hex())
|
|
}
|
|
|
|
/// List manifests published by this node.
|
|
pub async fn list_published(data_dir: &Path) -> Result<Vec<AppManifest>> {
|
|
let pub_dir = data_dir.join(MARKETPLACE_DIR).join(PUBLISHED_DIR);
|
|
if !pub_dir.exists() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let mut manifests = Vec::new();
|
|
let mut entries = fs::read_dir(&pub_dir).await.context("Reading published dir")?;
|
|
while let Some(entry) = entries.next_entry().await? {
|
|
let path = entry.path();
|
|
if path.extension().map(|e| e == "json").unwrap_or(false) {
|
|
let data = fs::read_to_string(&path).await?;
|
|
if let Ok(manifest) = serde_json::from_str::<AppManifest>(&data) {
|
|
manifests.push(manifest);
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(manifests)
|
|
}
|
|
|
|
// Re-use nostr client builder pattern from nostr_discovery
|
|
fn build_nostr_client(
|
|
keys: nostr_sdk::prelude::Keys,
|
|
tor_proxy: Option<&str>,
|
|
) -> Result<nostr_sdk::prelude::Client> {
|
|
use nostr_sdk::prelude::*;
|
|
let client = if let Some(proxy_str) = tor_proxy {
|
|
let addr: std::net::SocketAddr = proxy_str
|
|
.trim()
|
|
.parse()
|
|
.ok()
|
|
.ok_or_else(|| anyhow::anyhow!("Invalid Tor proxy: {}", proxy_str))?;
|
|
let connection = Connection::new()
|
|
.proxy(addr)
|
|
.target(ConnectionTarget::All);
|
|
let opts = ClientOptions::new().connection(connection);
|
|
Client::builder().signer(keys).opts(opts).build()
|
|
} else {
|
|
Client::new(keys)
|
|
};
|
|
Ok(client)
|
|
}
|
|
|
|
/// Load or create Nostr keys for marketplace publishing.
|
|
async fn load_or_create_keys(identity_dir: &Path) -> Result<nostr_sdk::prelude::Keys> {
|
|
use nostr_sdk::prelude::Keys;
|
|
|
|
let secret_path = identity_dir.join("nostr_secret");
|
|
if secret_path.exists() {
|
|
let hex_secret = fs::read_to_string(&secret_path)
|
|
.await
|
|
.context("Reading Nostr secret")?;
|
|
Keys::parse(hex_secret.trim()).context("Invalid Nostr secret")
|
|
} else {
|
|
let keys = Keys::generate();
|
|
fs::create_dir_all(identity_dir).await?;
|
|
fs::write(&secret_path, keys.secret_key().to_secret_hex()).await?;
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
tokio::fs::set_permissions(
|
|
&secret_path,
|
|
std::fs::Permissions::from_mode(0o600),
|
|
)
|
|
.await?;
|
|
}
|
|
Ok(keys)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn sample_manifest() -> AppManifest {
|
|
AppManifest {
|
|
app_id: "test-app".into(),
|
|
name: "Test App".into(),
|
|
version: "1.0.0".into(),
|
|
description: ManifestDescription::Detailed {
|
|
short: "A test app".into(),
|
|
long: "A longer description of the test app".into(),
|
|
},
|
|
author: ManifestAuthor {
|
|
name: "Test Dev".into(),
|
|
did: "did:key:z6MkTest123".into(),
|
|
nostr_pubkey: String::new(),
|
|
},
|
|
container: ManifestContainer {
|
|
image: "docker.io/test/app:1.0.0".into(),
|
|
ports: vec![PortMapping {
|
|
container: 8080,
|
|
host: 8180,
|
|
protocol: "tcp".into(),
|
|
}],
|
|
volumes: vec![],
|
|
env: HashMap::new(),
|
|
capabilities: vec![],
|
|
readonly_root: true,
|
|
no_new_privileges: true,
|
|
run_as_user: 1000,
|
|
},
|
|
category: AppCategory::Other,
|
|
icon_url: String::new(),
|
|
repo_url: "https://github.com/test/app".into(),
|
|
license: "MIT".into(),
|
|
min_archipelago_version: "0.1.0".into(),
|
|
dependencies: vec![],
|
|
signatures: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_valid_manifest() {
|
|
let manifest = sample_manifest();
|
|
let issues = validate_manifest(&manifest);
|
|
assert!(issues.is_empty(), "Expected no issues, got: {:?}", issues);
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_latest_tag() {
|
|
let mut manifest = sample_manifest();
|
|
manifest.container.image = "docker.io/test/app:latest".into();
|
|
let issues = validate_manifest(&manifest);
|
|
assert!(issues.iter().any(|i| i.contains("latest")));
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_root_user() {
|
|
let mut manifest = sample_manifest();
|
|
manifest.container.run_as_user = 0;
|
|
let issues = validate_manifest(&manifest);
|
|
assert!(issues.iter().any(|i| i.contains("run_as_user")));
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_missing_fields() {
|
|
let mut manifest = sample_manifest();
|
|
manifest.app_id = String::new();
|
|
manifest.container.image = String::new();
|
|
let issues = validate_manifest(&manifest);
|
|
assert!(issues.len() >= 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_trust_score_full() {
|
|
let manifest = sample_manifest();
|
|
let (score, tier) = calculate_trust_score(&manifest, 3, &["did:key:z6MkTest123".to_string()]);
|
|
// DID (30) + relay consensus 2-3 (12) + federation (20) + semver (10) + repo (5) + security clean (15) = 92
|
|
assert!(score >= 80, "Expected verified, got score={}", score);
|
|
assert_eq!(tier, "verified");
|
|
}
|
|
|
|
#[test]
|
|
fn test_trust_score_no_federation() {
|
|
let manifest = sample_manifest();
|
|
let (score, tier) = calculate_trust_score(&manifest, 1, &[]);
|
|
// DID (30) + 1 relay (5) + no federation (0) + semver (10) + repo (5) + security (15) = 65
|
|
assert_eq!(tier, "community");
|
|
assert!(score >= 50 && score < 80);
|
|
}
|
|
|
|
#[test]
|
|
fn test_trust_score_untrusted() {
|
|
let mut manifest = sample_manifest();
|
|
manifest.author.did = String::new();
|
|
manifest.repo_url = String::new();
|
|
manifest.version = "1".into();
|
|
manifest.container.readonly_root = false;
|
|
let (score, _tier) = calculate_trust_score(&manifest, 1, &[]);
|
|
assert!(score < 50, "Expected low score, got {}", score);
|
|
}
|
|
|
|
#[test]
|
|
fn test_manifest_serialization() {
|
|
let manifest = sample_manifest();
|
|
let json = serde_json::to_string(&manifest).unwrap();
|
|
let parsed: AppManifest = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(parsed.app_id, "test-app");
|
|
assert_eq!(parsed.description.short(), "A test app");
|
|
}
|
|
|
|
#[test]
|
|
fn test_category_display() {
|
|
assert_eq!(AppCategory::Money.to_string(), "money");
|
|
assert_eq!(AppCategory::Networking.to_string(), "networking");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_cache_persistence() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let cache = MarketplaceCache {
|
|
apps: vec![DiscoveredApp {
|
|
manifest: sample_manifest(),
|
|
trust_score: 75,
|
|
trust_tier: "community".into(),
|
|
relay_count: 2,
|
|
first_seen: "2026-03-10T00:00:00Z".into(),
|
|
nostr_pubkey: "abc123".into(),
|
|
}],
|
|
last_updated: "2026-03-10T00:00:00Z".into(),
|
|
};
|
|
save_cache(dir.path(), &cache).await.unwrap();
|
|
let loaded = load_cache(dir.path()).await.unwrap();
|
|
assert_eq!(loaded.apps.len(), 1);
|
|
assert_eq!(loaded.apps[0].manifest.app_id, "test-app");
|
|
}
|
|
}
|