Skip to main content

qualia_core_db/q42/volume/
magnet.rs

1//! WebTorrent magnets for unified `.q42` files and volume-set snapshots.
2//!
3//! One artifact → one SHA-1 info-hash (BEP 9 `urn:btih:`). A volume root
4//! publishes its own magnet plus one magnet per child segment. The daemon
5//! web-seeds each file at `/torrent/webseed/{info_hash}`.
6//!
7//! Magnets are Permissive Commons *transport*, not a default. Personal,
8//! medical, bilateral, and unmarked volumes are denied here; they travel
9//! on SocialWebNet or stay in Sanctuary.
10
11use std::fs;
12use std::io::{self, Read};
13use std::path::Path;
14
15use serde::Serialize;
16use sha1::{Digest, Sha1};
17
18use super::super::Q42Volume;
19use super::publication::{
20    classify_q42_path, classify_q42_volume_set, deny_public_publication, PublicationIntent,
21};
22
23/// A single-file magnet for one immutable Q42 artifact.
24#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
25pub struct Q42Magnet {
26    pub path: String,
27    pub display_name: String,
28    pub info_hash_sha1: String,
29    pub byte_length: u64,
30    pub magnet_uri: String,
31}
32
33/// Root + children, each with its own magnet (one info-hash per file).
34#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
35pub struct Q42VolumeSetMagnets {
36    pub root: Q42Magnet,
37    pub children: Vec<Q42Magnet>,
38}
39
40impl Q42Magnet {
41    pub fn for_path(path: &Path, webseed: Option<&str>) -> io::Result<Self> {
42        Self::for_path_with_intent(path, webseed, PublicationIntent::Default)
43    }
44
45    pub fn for_path_named(path: &Path, display_name: &str, webseed: Option<&str>) -> io::Result<Self> {
46        Self::for_path_named_with_intent(path, display_name, webseed, PublicationIntent::Default)
47    }
48
49    pub fn for_path_with_intent(
50        path: &Path,
51        webseed: Option<&str>,
52        intent: PublicationIntent,
53    ) -> io::Result<Self> {
54        let display = path
55            .file_name()
56            .and_then(|n| n.to_str())
57            .unwrap_or("volume.q42")
58            .to_string();
59        Self::for_path_named_with_intent(path, &display, webseed, intent)
60    }
61
62    pub fn for_path_named_with_intent(
63        path: &Path,
64        display_name: &str,
65        webseed: Option<&str>,
66        intent: PublicationIntent,
67    ) -> io::Result<Self> {
68        deny_public_publication(&classify_q42_path(path, intent)?)?;
69        let meta = fs::metadata(path)?;
70        if !meta.is_file() {
71            return Err(io::Error::new(
72                io::ErrorKind::InvalidInput,
73                "Q42 magnet requires a file",
74            ));
75        }
76        let info_hash_sha1 = sha1_hex_file(path)?;
77        let magnet_uri = compose_magnet(&info_hash_sha1, display_name, meta.len(), webseed);
78        Ok(Self {
79            path: path.display().to_string(),
80            display_name: display_name.to_string(),
81            info_hash_sha1,
82            byte_length: meta.len(),
83            magnet_uri,
84        })
85    }
86
87    /// Loopback daemon web-seed (`ws=`). Still fail-closed on Sanctuary volumes.
88    pub fn for_daemon_seed(path: &Path, display_name: &str, daemon_port: u16) -> io::Result<Self> {
89        Self::for_daemon_seed_with_intent(path, display_name, daemon_port, PublicationIntent::Default)
90    }
91
92    pub fn for_daemon_seed_with_intent(
93        path: &Path,
94        display_name: &str,
95        daemon_port: u16,
96        intent: PublicationIntent,
97    ) -> io::Result<Self> {
98        let hash = sha1_hex_file(path)?;
99        let ws = format!("http://127.0.0.1:{daemon_port}/torrent/webseed/{hash}");
100        Self::for_path_named_with_intent(path, display_name, Some(&ws), intent)
101    }
102}
103
104impl Q42VolumeSetMagnets {
105    pub fn for_root(path: &Path, webseed_base: Option<&str>) -> io::Result<Self> {
106        Self::for_root_with_intent(path, webseed_base, PublicationIntent::Default)
107    }
108
109    pub fn for_root_with_intent(
110        path: &Path,
111        webseed_base: Option<&str>,
112        intent: PublicationIntent,
113    ) -> io::Result<Self> {
114        deny_public_publication(&classify_q42_volume_set(path, intent)?)?;
115        let root_vol = Q42Volume::open(path)?;
116        let root_ws = webseed_for(path, webseed_base)?;
117        let root = Q42Magnet::for_path(path, root_ws.as_deref())?;
118        let Some(manifest) = root_vol.volume_manifest()? else {
119            return Ok(Self {
120                root,
121                children: Vec::new(),
122            });
123        };
124        let parent = path.parent().unwrap_or(Path::new("."));
125        let mut children = Vec::new();
126        for entry in &manifest.segments {
127            let child = parent.join(&entry.locator);
128            let ws = webseed_for(&child, webseed_base)?;
129            children.push(Q42Magnet::for_path_with_intent(
130                &child,
131                ws.as_deref(),
132                intent,
133            )?);
134        }
135        Ok(Self { root, children })
136    }
137}
138
139pub fn compose_magnet(
140    info_hash_sha1: &str,
141    display_name: &str,
142    byte_length: u64,
143    webseed: Option<&str>,
144) -> String {
145    let hash = info_hash_sha1.trim().to_ascii_lowercase();
146    let dn = urlencoding::encode(display_name);
147    let mut uri = format!("magnet:?xt=urn:btih:{hash}&dn={dn}&xl={byte_length}");
148    if let Some(ws) = webseed {
149        uri.push_str("&ws=");
150        uri.push_str(&urlencoding::encode(ws));
151        uri.push_str("&xs=");
152        uri.push_str(&urlencoding::encode(ws));
153    }
154    uri
155}
156
157pub fn sha1_hex_file(path: &Path) -> io::Result<String> {
158    let mut file = fs::File::open(path)?;
159    let mut hasher = Sha1::new();
160    let mut buf = [0u8; 64 * 1024];
161    loop {
162        let n = file.read(&mut buf)?;
163        if n == 0 {
164            break;
165        }
166        hasher.update(&buf[..n]);
167    }
168    Ok(hasher.finalize().iter().map(|b| format!("{b:02x}")).collect())
169}
170
171fn webseed_for(path: &Path, base: Option<&str>) -> io::Result<Option<String>> {
172    let Some(base) = base else {
173        return Ok(None);
174    };
175    if base.contains("{hash}") {
176        let hash = sha1_hex_file(path)?;
177        return Ok(Some(base.replace("{hash}", &hash)));
178    }
179    Ok(Some(base.trim_end_matches('/').to_string()))
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use crate::q42_volume::write_unified_volume;
186    use crate::NQuin;
187    use std::collections::HashMap;
188
189    #[test]
190    fn unmarked_volume_cannot_mint_a_magnet() {
191        let file = tempfile::NamedTempFile::new().unwrap();
192        write_unified_volume(
193            file.path(),
194            &HashMap::new(),
195            &[(3, 3)],
196            &[vec![NQuin {
197                subject: 1,
198                predicate: 2,
199                object: 3,
200                context: 0,
201                metadata: 0,
202                parity: 0,
203            }]],
204        )
205        .unwrap();
206        let err = Q42Magnet::for_daemon_seed(file.path(), "demo.q42", 4242).unwrap_err();
207        assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
208        assert!(err.to_string().contains("publication denied"));
209    }
210
211    #[test]
212    fn commons_catalog_magnet_has_btih_xl_and_webseed() {
213        let file = tempfile::NamedTempFile::new().unwrap();
214        write_unified_volume(
215            file.path(),
216            &HashMap::new(),
217            &[(3, 3)],
218            &[vec![NQuin {
219                subject: 1,
220                predicate: 2,
221                object: 3,
222                context: 0,
223                metadata: 0,
224                parity: 0,
225            }]],
226        )
227        .unwrap();
228        let magnet = Q42Magnet::for_daemon_seed_with_intent(
229            file.path(),
230            "demo.q42",
231            4242,
232            PublicationIntent::CommonsCatalog,
233        )
234        .unwrap();
235        assert!(magnet.magnet_uri.starts_with("magnet:?xt=urn:btih:"));
236        assert!(magnet.magnet_uri.contains("&dn=demo.q42"));
237        assert!(magnet.magnet_uri.contains(&format!("&xl={}", magnet.byte_length)));
238        assert!(magnet.magnet_uri.contains("webseed"));
239        assert_eq!(magnet.info_hash_sha1.len(), 40);
240    }
241}