qualia_core_db/q42/volume/
magnet.rs1use 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#[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#[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 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}