Skip to main content

hypha/visitor/
mod.rs

1//! Visitor module for tasting and resolving spores from the network.
2//!
3//! Resolution flow:
4//! 1. Parse CMN URI (cmn://domain/hash)
5//! 2. Get cmn.json (from cache or fetch)
6//! 3. Use endpoint template to build actual URL
7//! 4. Fetch and verify spore manifest
8//! 5. Verify signature against public key from cmn.json
9//! 6. Download content and verify hash matches URI
10
11use serde::Serialize;
12use serde_json::json;
13use std::path::Path;
14use std::process::ExitCode;
15
16use crate::api::Output;
17use crate::cache::{CacheDir, DomainCache, TasteVerdictCache};
18use substrate::{CmnEntry, CmnUri, PrettyJson};
19
20mod absorb;
21mod bond;
22mod crypto;
23mod distribution;
24pub(crate) mod extract;
25mod fetch;
26mod grow;
27mod lineage;
28mod search;
29mod sense;
30mod spawn;
31mod taste;
32mod verify;
33
34/// Structured error for archive extraction and file copy operations.
35#[derive(Debug, thiserror::Error)]
36pub enum ExtractError {
37    /// Content is actively dangerous (symlinks, path traversal, zip bombs).
38    /// Treated as an unverified delivery failure; callers clean up without
39    /// persisting a toxic taste verdict.
40    #[error("MALICIOUS: {0}")]
41    Malicious(String),
42    /// Local receive/cache policy rejected otherwise valid content.
43    #[error("{0}")]
44    PolicyRejected(String),
45    /// Non-malicious failure (I/O error, unsupported format, etc.).
46    #[error("{0}")]
47    Failed(String),
48}
49
50impl ExtractError {
51    pub fn is_malicious(&self) -> bool {
52        matches!(self, Self::Malicious(_))
53    }
54
55    pub fn is_policy_rejected(&self) -> bool {
56        matches!(self, Self::PolicyRejected(_))
57    }
58}
59
60impl From<String> for ExtractError {
61    fn from(s: String) -> Self {
62        Self::Failed(s)
63    }
64}
65
66impl From<substrate::archive::ExtractError> for ExtractError {
67    fn from(e: substrate::archive::ExtractError) -> Self {
68        match e {
69            substrate::archive::ExtractError::Malicious(msg) => Self::Malicious(msg),
70            substrate::archive::ExtractError::Failed(msg) => Self::Failed(msg),
71        }
72    }
73}
74
75// Re-export extract module items for internal use
76use extract::LimitedWriter;
77pub(crate) use extract::{
78    decode_delta_to_raw_tar_file, download_and_extract_to_dir, download_file,
79    ensure_no_rejected_path_components, load_old_archive_dictionary, rejected_path_component,
80    DeltaByteBudget, ExtractLimits,
81};
82
83// Re-export all public items so external callers don't break
84pub use absorb::{absorb, handle_absorb};
85pub use bond::{bond_fetch, handle_bond_fetch};
86pub use crypto::{
87    embedded_spore_author_key, fetch_spore_manifest, get_cmn_entry, verify_content_hash,
88    verify_manifest_two_key_signatures, verify_spore_with_key_trust,
89};
90use distribution::{
91    build_archive_delta_url_from_endpoint, build_archive_url_from_endpoint, dist_git_ref,
92    dist_git_url, dist_has_type, is_safe_bond_dir_name,
93};
94pub(crate) use fetch::fetch_spore_to_cache;
95use fetch::{clone_git_to_dir, fetch_bonds, fetch_cmn_json, fetch_opts};
96pub use grow::{grow, handle_grow};
97pub use lineage::{handle_lineage, lineage_in, lineage_out};
98pub use search::{handle_search, search, search_with_bond};
99pub use sense::{handle_sense, sense, sense_with_id};
100pub use spawn::{handle_spawn, spawn};
101pub use taste::{check_taste, check_taste_verdict_for_replicate, handle_taste, taste};
102pub(crate) use verify::fetch_verified_spore;
103use verify::{
104    can_synapse_fallback, mtime_epoch_ms, primary_capsule, resolve_default_synapse_url,
105    verify_downloaded_content, warn_remove_dir,
106};
107
108// Cross-submodule imports: these are brought into scope here so that
109// submodules using `use super::*` can access sibling module functions.
110use bond::bond_in_dir;
111use crypto::{verify_manifest_capsule_signature, verify_manifest_core_signature};
112use spawn::{cache_archive_raw_file, download_and_apply_delta, extract_archive};
113
114// URI parsing tests are in substrate/src/uri.rs
115
116#[cfg(test)]
117#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
118mod tests {
119
120    use super::*;
121
122    fn sanitize_for_path(input: &str) -> String {
123        substrate::local_dir_name(None, Some(input), "spore")
124    }
125
126    #[test]
127    fn test_sanitize_for_path_basic() {
128        assert_eq!(sanitize_for_path("cmn-spec"), "cmn-spec");
129        assert_eq!(sanitize_for_path("my_project"), "my-project");
130    }
131
132    #[test]
133    fn test_sanitize_for_path_spaces() {
134        assert_eq!(
135            sanitize_for_path("CMN Protocol Specification"),
136            "cmn-protocol-specification"
137        );
138        assert_eq!(sanitize_for_path("a  b"), "a-b");
139    }
140
141    #[test]
142    fn test_sanitize_for_path_preserves_version_dots() {
143        assert_eq!(sanitize_for_path("Ubuntu 16.04"), "ubuntu-16.04");
144        assert_eq!(sanitize_for_path("v1.2.3"), "v1.2.3");
145        assert_eq!(sanitize_for_path("a.b"), "a-b");
146    }
147
148    #[test]
149    fn test_sanitize_for_path_forbidden_chars() {
150        assert_eq!(sanitize_for_path("foo/bar"), "foo-bar");
151        assert_eq!(sanitize_for_path("a:b*c?d"), "a-b-c-d");
152    }
153
154    #[test]
155    fn test_sanitize_for_path_unicode_preserved() {
156        assert_eq!(sanitize_for_path("CMN协议规范"), "cmn协议规范");
157        assert_eq!(sanitize_for_path("数据库工具"), "数据库工具");
158        assert_eq!(sanitize_for_path("cafe\u{301}-utils"), "cafe-utils");
159    }
160
161    #[test]
162    fn test_sanitize_for_path_empty_fallback() {
163        assert_eq!(sanitize_for_path(""), "spore");
164        assert_eq!(sanitize_for_path("---"), "spore");
165    }
166
167    #[test]
168    fn test_sanitize_for_path_traversal_safe() {
169        assert_eq!(sanitize_for_path(".."), "spore");
170        assert_eq!(sanitize_for_path("."), "spore");
171        assert_eq!(sanitize_for_path("../etc"), "etc");
172        assert_eq!(sanitize_for_path(".git"), "git");
173        assert_eq!(sanitize_for_path(".cmn"), "cmn");
174        assert_eq!(sanitize_for_path("...hidden"), "hidden");
175    }
176
177    #[test]
178    fn test_sanitize_for_path_control_chars() {
179        assert_eq!(sanitize_for_path("foo\0bar"), "foo-bar");
180        assert_eq!(sanitize_for_path("\x01\x02"), "spore");
181        assert_eq!(sanitize_for_path("ok\x7f"), "ok");
182    }
183
184    #[test]
185    fn test_spawned_from_hash_present() {
186        let manifest = serde_json::json!({
187            "$schema": "https://cmn.dev/schemas/v1/spore.json",
188            "capsule": {
189                "uri": "cmn://example.com/b3.child",
190                "core": {
191                    "name": "test",
192                    "domain": "example.com",
193                    "key": "ed25519.5XmkQ9vZP8nL",
194                    "synopsis": "Test",
195                    "intent": ["Testing"],
196                    "license": "MIT",
197                    "mutations": [],
198                    "size_bytes": 512,
199                    "updated_at_epoch_ms": 1700000000000_u64,
200                    "bonds": [
201                        {"uri": "cmn://example.com/b3.3yMR7vZQ9hL", "relation": "spawned_from"}
202                    ],
203                    "tree": { "algorithm": "blob_tree_blake3_nfc", "exclude_names": [], "follow_rules": [] }
204                },
205                "core_signature": "sig",
206                "dist": [{"type": "archive"}]
207            },
208            "capsule_signature": "sig"
209        });
210        assert_eq!(
211            grow::spawned_from_hash(&manifest),
212            Some("b3.3yMR7vZQ9hL".to_string())
213        );
214    }
215
216    #[test]
217    fn test_spawned_from_hash_missing() {
218        let manifest = serde_json::json!({
219            "$schema": "https://cmn.dev/schemas/v1/spore.json",
220            "capsule": {
221                "uri": "cmn://example.com/b3.child",
222                "core": {
223                    "name": "test",
224                    "domain": "example.com",
225                    "key": "ed25519.5XmkQ9vZP8nL",
226                    "synopsis": "Test",
227                    "intent": ["Testing"],
228                    "license": "MIT",
229                    "mutations": [],
230                    "size_bytes": 512,
231                    "updated_at_epoch_ms": 1700000000000_u64,
232                    "bonds": [
233                        {"uri": "cmn://example.com/b3.8cQnH4xPmZ2v", "relation": "depends_on"}
234                    ],
235                    "tree": { "algorithm": "blob_tree_blake3_nfc", "exclude_names": [], "follow_rules": [] }
236                },
237                "core_signature": "sig",
238                "dist": [{"type": "archive"}]
239            },
240            "capsule_signature": "sig"
241        });
242        assert_eq!(grow::spawned_from_hash(&manifest), None);
243    }
244
245    #[test]
246    fn test_spawned_from_hash_no_bonds() {
247        let manifest = serde_json::json!({
248            "$schema": "https://cmn.dev/schemas/v1/spore.json",
249            "capsule": {
250                "uri": "cmn://example.com/b3.child",
251                "core": {
252                    "name": "test",
253                    "domain": "example.com",
254                    "synopsis": "Test",
255                    "intent": ["Testing"],
256                    "license": "MIT"
257                },
258                "core_signature": "sig"
259            },
260            "capsule_signature": "sig"
261        });
262        assert_eq!(grow::spawned_from_hash(&manifest), None);
263    }
264
265    #[test]
266    fn test_spawned_from_hash_empty_manifest() {
267        let manifest = serde_json::json!({});
268        assert_eq!(grow::spawned_from_hash(&manifest), None);
269    }
270
271    fn test_client() -> reqwest::Client {
272        reqwest::Client::builder()
273            .timeout(std::time::Duration::from_secs(1))
274            .build()
275            .unwrap()
276    }
277
278    /// Verify substrate::client::search accepts the bond_filter parameter.
279    /// Uses a non-routable address so the HTTP call fails fast.
280    #[tokio::test]
281    async fn test_fetch_search_with_bond() {
282        let result = substrate::client::search(
283            &test_client(),
284            "http://127.0.0.1:1",
285            "test",
286            None,
287            None,
288            Some("spawned_from:cmn://d.dev/b3.3yMR7vZQ9hL"),
289            5,
290            Default::default(),
291        )
292        .await;
293        assert!(result.is_err());
294    }
295
296    /// Verify substrate::client::search works without bond_filter.
297    #[tokio::test]
298    async fn test_fetch_search_without_bond() {
299        let result = substrate::client::search(
300            &test_client(),
301            "http://127.0.0.1:1",
302            "test",
303            Some("cmn.dev"),
304            Some("MIT"),
305            None,
306            10,
307            Default::default(),
308        )
309        .await;
310        assert!(result.is_err());
311    }
312
313    /// Verify substrate::client::search with comma-separated bond filters.
314    #[tokio::test]
315    async fn test_fetch_search_with_multi_bond() {
316        let result = substrate::client::search(
317            &test_client(),
318            "http://127.0.0.1:1",
319            "tools",
320            None,
321            None,
322            Some("spawned_from:cmn://a.dev/b3.3yMR7vZQ9hL,follows:cmn://b.dev/b3.8cQnH4xPmZ2v"),
323            20,
324            Default::default(),
325        )
326        .await;
327        assert!(result.is_err());
328    }
329
330    /// search_with_bond with bond_filter=None delegates to the same path as search().
331    /// Both should produce the same error when pointed at an unreachable synapse.
332    #[tokio::test]
333    async fn test_search_with_bond_none_delegates() {
334        let result_with_ref = search_with_bond(
335            "test",
336            Some("http://127.0.0.1:1"),
337            None,
338            None,
339            None,
340            None,
341            20,
342            &crate::NoopSink,
343        )
344        .await;
345        let result_plain = search(
346            "test",
347            Some("http://127.0.0.1:1"),
348            None,
349            None,
350            None,
351            20,
352            &crate::NoopSink,
353        )
354        .await;
355        assert!(result_with_ref.is_err());
356        assert!(result_plain.is_err());
357    }
358
359    /// search_with_bond with a bond_filter should also fail at the HTTP level
360    /// (not at argument handling).
361    #[tokio::test]
362    async fn test_search_with_bond_passes_bond_through() {
363        let result = search_with_bond(
364            "http client",
365            Some("http://127.0.0.1:1"),
366            None,
367            Some("cmn.dev"),
368            Some("MIT"),
369            Some("spawned_from:cmn://cmn.dev/b3.3yMR7vZQ9hL"),
370            10,
371            &crate::NoopSink,
372        )
373        .await;
374        assert!(result.is_err());
375        let err = result.unwrap_err().to_string();
376        // Should fail at HTTP, not at bond parsing
377        assert!(
378            err.contains("synapse_error"),
379            "should fail at HTTP level: {}",
380            err
381        );
382    }
383}