1use std::io;
13use std::path::Path;
14
15use serde::Serialize;
16
17use super::super::{
18 Q42Volume, FLAG_PERMISSIVE_COMMONS, FLAG_SANCTUARY, QUIN_SIZE, SUPERBLOCK_HEADER,
19 SUPERBLOCK_SIZE,
20};
21use crate::{NQuin, PermissiveRoutingLane, QUINS_PER_BLOCK};
22
23#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
25pub enum PublicationIntent {
26 Default,
28 CommonsCatalog,
31}
32
33#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
35pub enum Q42PublicationClass {
36 PermissiveCommons,
38 CommonsGated,
40 UnmarkedLocal,
42 Sanctuary,
44 MixedFailClosed,
46}
47
48#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
50pub enum Q42Transport {
51 WebTorrentCommons,
52 SocialWebNetBilateral,
53 LocalSanctuaryOnly,
54}
55
56#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize)]
57pub struct ClassificationCounts {
58 pub quins_scanned: u64,
59 pub public: u64,
60 pub restricted: u64,
61 pub classified: u64,
62 pub professional_tier: u64,
63 pub legal_tier: u64,
64 pub medical_tier: u64,
65 pub fiduciary_tier: u64,
66 pub passthrough: u64,
67 pub commons_lane: u64,
68 pub bilateral: u64,
69 pub spatial: u64,
70 pub decode_failures: u64,
71}
72
73#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
74pub struct Q42PublicationVerdict {
75 pub class: Q42PublicationClass,
76 pub may_emit_public_magnet: bool,
77 pub may_http_webseed: bool,
78 pub may_ipfs_pin: bool,
79 pub transport: Q42Transport,
80 pub reason: String,
81 pub counts: ClassificationCounts,
82 pub header_commons_flag: bool,
83 pub header_sanctuary_flag: bool,
84}
85
86impl Q42PublicationClass {
87 pub fn as_str(self) -> &'static str {
88 match self {
89 Self::PermissiveCommons => "permissive-commons",
90 Self::CommonsGated => "commons-gated",
91 Self::UnmarkedLocal => "unmarked-local",
92 Self::Sanctuary => "sanctuary",
93 Self::MixedFailClosed => "mixed-fail-closed",
94 }
95 }
96}
97
98impl Q42Transport {
99 pub fn as_str(self) -> &'static str {
100 match self {
101 Self::WebTorrentCommons => {
102 "WebTorrent Permissive Commons (hash-addressed; not open-data)"
103 }
104 Self::SocialWebNetBilateral => {
105 "SocialWebNet (pairwise DID / WireGuard). No public magnet."
106 }
107 Self::LocalSanctuaryOnly => "local Sanctuary only. No public magnet, web-seed, or IPFS.",
108 }
109 }
110}
111
112pub fn quin_requires_sanctuary(quin: &NQuin) -> bool {
114 match quin.get_sensitivity_byte() {
115 NQuin::SENSITIVITY_RESTRICTED | NQuin::SENSITIVITY_CLASSIFIED => return true,
116 _ => {}
117 }
118 match quin.get_sensitivity_tier() {
119 NQuin::SENSITIVITY_TIER_LEGAL
120 | NQuin::SENSITIVITY_TIER_MEDICAL
121 | NQuin::SENSITIVITY_TIER_FIDUCIARY => return true,
122 _ => {}
123 }
124 quin.identify_routing_lane() == PermissiveRoutingLane::EnforceBilateralMicroCommons
125}
126
127pub fn classify_q42_path(
128 path: &Path,
129 intent: PublicationIntent,
130) -> io::Result<Q42PublicationVerdict> {
131 let volume = Q42Volume::open(path)?;
132 Ok(classify_q42_volume(&volume, intent))
133}
134
135pub fn classify_q42_volume(volume: &Q42Volume, intent: PublicationIntent) -> Q42PublicationVerdict {
136 let flags = volume.header().flags;
137 let header_commons = flags & FLAG_PERMISSIVE_COMMONS != 0;
138 let header_sanctuary = flags & FLAG_SANCTUARY != 0;
139 let mut counts = ClassificationCounts::default();
140 if volume.block_count() > 0 {
141 let mut decoded = [0u8; SUPERBLOCK_SIZE];
142 for block_index in 0..volume.block_count() as usize {
143 if volume.read_superblock_into(block_index, &mut decoded).is_err() {
144 counts.decode_failures += 1;
145 continue;
146 }
147 let live = u64::from_le_bytes(decoded[16..24].try_into().unwrap()) as usize;
148 if live == 0 || live > QUINS_PER_BLOCK {
149 counts.decode_failures += 1;
150 continue;
151 }
152 for quin_index in 0..live {
153 let offset = SUPERBLOCK_HEADER + quin_index * QUIN_SIZE;
154 let quin: NQuin =
155 bytemuck::pod_read_unaligned(&decoded[offset..offset + QUIN_SIZE]);
156 accumulate(&mut counts, &quin);
157 }
158 }
159 }
160 decide(header_commons, header_sanctuary, intent, counts)
161}
162
163pub fn classify_q42_volume_set(
165 root: &Path,
166 intent: PublicationIntent,
167) -> io::Result<Q42PublicationVerdict> {
168 let root_verdict = classify_q42_path(root, intent)?;
169 if !root_verdict.may_emit_public_magnet {
170 return Ok(root_verdict);
171 }
172 let volume = Q42Volume::open(root)?;
173 let Some(manifest) = volume.volume_manifest()? else {
174 return Ok(root_verdict);
175 };
176 let parent = root.parent().unwrap_or(Path::new("."));
177 for entry in &manifest.segments {
178 let child = parent.join(&entry.locator);
179 let child_verdict = classify_q42_path(&child, intent)?;
180 if !child_verdict.may_emit_public_magnet {
181 return Ok(child_verdict);
182 }
183 }
184 Ok(root_verdict)
185}
186
187pub fn deny_public_publication(verdict: &Q42PublicationVerdict) -> io::Result<()> {
188 if verdict.may_emit_public_magnet {
189 return Ok(());
190 }
191 Err(io::Error::new(
192 io::ErrorKind::PermissionDenied,
193 verdict.reason.clone(),
194 ))
195}
196
197fn accumulate(counts: &mut ClassificationCounts, quin: &NQuin) {
198 counts.quins_scanned += 1;
199 match quin.get_sensitivity_byte() {
200 NQuin::SENSITIVITY_RESTRICTED => counts.restricted += 1,
201 NQuin::SENSITIVITY_CLASSIFIED => counts.classified += 1,
202 _ => counts.public += 1,
203 }
204 match quin.get_sensitivity_tier() {
205 NQuin::SENSITIVITY_TIER_PROFESSIONAL => counts.professional_tier += 1,
206 NQuin::SENSITIVITY_TIER_LEGAL => counts.legal_tier += 1,
207 NQuin::SENSITIVITY_TIER_MEDICAL => counts.medical_tier += 1,
208 NQuin::SENSITIVITY_TIER_FIDUCIARY => counts.fiduciary_tier += 1,
209 _ => {}
210 }
211 match quin.identify_routing_lane() {
212 PermissiveRoutingLane::PassthroughStandard => counts.passthrough += 1,
213 PermissiveRoutingLane::EnforcePermissiveCommons => counts.commons_lane += 1,
214 PermissiveRoutingLane::EnforceBilateralMicroCommons => counts.bilateral += 1,
215 PermissiveRoutingLane::SpatiotemporalAmbiguous => counts.spatial += 1,
216 }
217}
218
219fn sanctuary_quin_count(counts: &ClassificationCounts) -> u64 {
220 counts.restricted
221 + counts.classified
222 + counts.legal_tier
223 + counts.medical_tier
224 + counts.fiduciary_tier
225 + counts.bilateral
226}
227
228fn decide(
229 header_commons: bool,
230 header_sanctuary: bool,
231 intent: PublicationIntent,
232 counts: ClassificationCounts,
233) -> Q42PublicationVerdict {
234 let sanctuary_bits = header_sanctuary
235 || sanctuary_quin_count(&counts) > 0
236 || counts.decode_failures > 0;
237 let commons_bits = header_commons
238 || intent == PublicationIntent::CommonsCatalog
239 || counts.commons_lane > 0;
240
241 let (class, reason) = if sanctuary_bits && commons_bits {
242 (
243 Q42PublicationClass::MixedFailClosed,
244 "Q42 publication denied: this file mixes Permissive Commons material with Selfhood, medical, legal, fiduciary, classified, or bilateral Quins. Split the volume. Medical records of a person (including a politically exposed person) must not share a public magnet with a catalog.".into(),
245 )
246 } else if sanctuary_bits {
247 let why = if counts.decode_failures > 0 {
248 "Q42 publication denied: SuperBlocks could not be classified; fail closed."
249 } else if header_sanctuary {
250 "Q42 publication denied: FLAG_SANCTUARY. This volume stays in Sanctuary / SocialWebNet."
251 } else {
252 "Q42 publication denied: restricted, classified, medical, legal, fiduciary, or bilateral Quins. Public magnet, HTTP web-seed, and IPFS are Commons transport, not a dump of a person's file."
253 };
254 (Q42PublicationClass::Sanctuary, why.into())
255 } else if header_commons || intent == PublicationIntent::CommonsCatalog {
256 if counts.commons_lane > 0 && !header_commons && intent != PublicationIntent::CommonsCatalog
257 {
258 (
259 Q42PublicationClass::CommonsGated,
260 "Permissive Commons (gated lane). Magnet is hash-addressed Commons transport, not open data.".into(),
261 )
262 } else if counts.commons_lane > 0 {
263 (
264 Q42PublicationClass::CommonsGated,
265 "Permissive Commons catalog with EnforcePermissiveCommons Quins. Not open data; TrustGroup still applies on consume.".into(),
266 )
267 } else {
268 (
269 Q42PublicationClass::PermissiveCommons,
270 "Permissive Commons catalog. Magnet and web-seed are allowed as ICN transport.".into(),
271 )
272 }
273 } else if counts.commons_lane > 0 {
274 (
275 Q42PublicationClass::CommonsGated,
276 "Permissive Commons lane is present and no Selfhood bits were found.".into(),
277 )
278 } else {
279 (
280 Q42PublicationClass::UnmarkedLocal,
281 "Q42 publication denied: this file does not declare Permissive Commons and was not marked --commons. Unmarked volumes stay local so personal and medical records cannot become bot-scrapeable magnets by default. Catalog ontologies: set FLAG_PERMISSIVE_COMMONS or pass --commons. Person-to-person: SocialWebNet.".into(),
282 )
283 };
284
285 let may_public = matches!(
286 class,
287 Q42PublicationClass::PermissiveCommons | Q42PublicationClass::CommonsGated
288 );
289 let transport = match class {
290 Q42PublicationClass::PermissiveCommons | Q42PublicationClass::CommonsGated => {
291 Q42Transport::WebTorrentCommons
292 }
293 Q42PublicationClass::Sanctuary | Q42PublicationClass::MixedFailClosed => {
294 if counts.bilateral > 0 {
295 Q42Transport::SocialWebNetBilateral
296 } else {
297 Q42Transport::LocalSanctuaryOnly
298 }
299 }
300 Q42PublicationClass::UnmarkedLocal => Q42Transport::LocalSanctuaryOnly,
301 };
302
303 Q42PublicationVerdict {
304 class,
305 may_emit_public_magnet: may_public,
306 may_http_webseed: may_public,
307 may_ipfs_pin: may_public,
308 transport,
309 reason,
310 counts,
311 header_commons_flag: header_commons,
312 header_sanctuary_flag: header_sanctuary,
313 }
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319 use crate::q42_volume::{write_unified_volume, StreamingQ42VolumeWriter};
320 use std::collections::HashMap;
321 use tempfile::NamedTempFile;
322
323 fn public_quin(object: u64) -> NQuin {
324 NQuin {
325 subject: 1,
326 predicate: 2,
327 object,
328 context: 0,
329 metadata: 0,
330 parity: 1 ^ 2 ^ object,
331 }
332 }
333
334 fn medical_quin(object: u64) -> NQuin {
335 let mut q = public_quin(object);
336 q.set_sensitivity_byte(NQuin::SENSITIVITY_CLASSIFIED);
337 q.set_sensitivity_tier(NQuin::SENSITIVITY_TIER_MEDICAL);
338 q.parity = q.subject ^ q.predicate ^ q.object ^ q.context;
339 q
340 }
341
342 fn bilateral_quin(object: u64) -> NQuin {
343 let mut q = public_quin(object);
344 q.metadata |= 0b10u64 << 61;
345 q.parity = q.subject ^ q.predicate ^ q.object ^ q.context;
346 q
347 }
348
349 fn write_blocks(path: &Path, quins: &[NQuin]) {
350 let first = quins[0].object;
351 let last = quins[quins.len() - 1].object;
352 write_unified_volume(
353 path,
354 &HashMap::new(),
355 &[(first, last)],
356 &[quins.to_vec()],
357 )
358 .unwrap();
359 }
360
361 #[test]
362 fn unmarked_volume_is_local_only() {
363 let file = NamedTempFile::new().unwrap();
364 write_blocks(file.path(), &[public_quin(3)]);
365 let verdict = classify_q42_path(file.path(), PublicationIntent::Default).unwrap();
366 assert_eq!(verdict.class, Q42PublicationClass::UnmarkedLocal);
367 assert!(!verdict.may_emit_public_magnet);
368 assert!(!verdict.may_http_webseed);
369 assert!(!verdict.may_ipfs_pin);
370 }
371
372 #[test]
373 fn commons_intent_allows_unmarked_public_catalog() {
374 let file = NamedTempFile::new().unwrap();
375 write_blocks(file.path(), &[public_quin(3)]);
376 let verdict = classify_q42_path(file.path(), PublicationIntent::CommonsCatalog).unwrap();
377 assert_eq!(verdict.class, Q42PublicationClass::PermissiveCommons);
378 assert!(verdict.may_emit_public_magnet);
379 }
380
381 #[test]
382 fn header_commons_flag_allows_magnet() {
383 let file = NamedTempFile::new().unwrap();
384 let mut writer = StreamingQ42VolumeWriter::new(&HashMap::new()).unwrap();
385 writer.declare_permissive_commons();
386 writer.push_block(0, &[public_quin(3)]).unwrap();
387 writer.finish(file.path()).unwrap();
388 let verdict = classify_q42_path(file.path(), PublicationIntent::Default).unwrap();
389 assert!(verdict.header_commons_flag);
390 assert!(verdict.may_emit_public_magnet);
391 assert_eq!(verdict.class, Q42PublicationClass::PermissiveCommons);
392 }
393
394 #[test]
395 fn medical_classified_is_sanctuary() {
396 let file = NamedTempFile::new().unwrap();
397 write_blocks(file.path(), &[medical_quin(3)]);
398 let verdict = classify_q42_path(file.path(), PublicationIntent::Default).unwrap();
399 assert_eq!(verdict.class, Q42PublicationClass::Sanctuary);
400 assert!(!verdict.may_emit_public_magnet);
401 assert!(verdict.header_sanctuary_flag);
402 assert_eq!(verdict.counts.classified, 1);
403 assert_eq!(verdict.counts.medical_tier, 1);
404 }
405
406 #[test]
407 fn commons_intent_cannot_override_medical() {
408 let file = NamedTempFile::new().unwrap();
409 write_blocks(file.path(), &[medical_quin(3)]);
410 let verdict = classify_q42_path(file.path(), PublicationIntent::CommonsCatalog).unwrap();
411 assert_eq!(verdict.class, Q42PublicationClass::MixedFailClosed);
412 assert!(!verdict.may_emit_public_magnet);
413 }
414
415 #[test]
416 fn bilateral_lane_is_social_webnet() {
417 let file = NamedTempFile::new().unwrap();
418 write_blocks(file.path(), &[bilateral_quin(3)]);
419 let verdict = classify_q42_path(file.path(), PublicationIntent::Default).unwrap();
420 assert_eq!(verdict.class, Q42PublicationClass::Sanctuary);
421 assert_eq!(verdict.transport, Q42Transport::SocialWebNetBilateral);
422 assert!(!verdict.may_emit_public_magnet);
423 }
424
425 #[test]
426 fn mixed_commons_lane_and_classified_denies() {
427 let file = NamedTempFile::new().unwrap();
428 let mut commons = public_quin(3);
429 commons.metadata |= 0b01u64 << 61;
430 write_blocks(file.path(), &[commons, medical_quin(4)]);
431 let verdict = classify_q42_path(file.path(), PublicationIntent::Default).unwrap();
432 assert_eq!(verdict.class, Q42PublicationClass::MixedFailClosed);
433 assert!(!verdict.may_emit_public_magnet);
434 }
435}