1use 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#[derive(Debug, thiserror::Error)]
36pub enum ExtractError {
37 #[error("MALICIOUS: {0}")]
41 Malicious(String),
42 #[error("{0}")]
44 PolicyRejected(String),
45 #[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
75use 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
83pub 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
108use 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#[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 #[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 #[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 #[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 #[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 #[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 assert!(
378 err.contains("synapse_error"),
379 "should fail at HTTP level: {}",
380 err
381 );
382 }
383}