Skip to main content

termivar_scanner/
recon_snapshot.rs

1//! Strict, bounded import of finite offline reconnaissance snapshots.
2//!
3//! A snapshot is caller-supplied reference material. Parsing computes the
4//! identity of the exact input bytes, but it does not authenticate any source
5//! declaration, verify collection times or rights, contact a provider or
6//! target, decompress an archive, or turn a listed value into a finding. Every
7//! returned record remains a source-qualified, unverified hypothesis.
8
9use std::{
10    collections::{BTreeMap, BTreeSet},
11    fmt,
12    mem::size_of,
13    net::IpAddr,
14};
15
16use serde::{
17    de::{self, DeserializeSeed, MapAccess, SeqAccess, Visitor},
18    Deserialize, Deserializer,
19};
20use serde_json::{Number, Value};
21use sha2::{Digest, Sha256};
22use thiserror::Error;
23
24/// The only reconnaissance snapshot schema accepted by this module.
25pub const RECONNAISSANCE_SNAPSHOT_SCHEMA: &str = "security.recon-snapshot/v1";
26/// Stable parser and interpretation policy applied to the V1 schema.
27pub const RECONNAISSANCE_SNAPSHOT_POLICY: &str = "termivar.recon-snapshot-import/v1";
28/// Maximum accepted exact input size.
29pub const MAX_RECONNAISSANCE_SNAPSHOT_INPUT_BYTES: usize = 1024 * 1024;
30/// Concise integration alias for the exact input-size ceiling.
31pub const MAX_RECON_SNAPSHOT_BYTES: usize = MAX_RECONNAISSANCE_SNAPSHOT_INPUT_BYTES;
32/// Maximum number of declared sources in one snapshot.
33pub const MAX_RECONNAISSANCE_SNAPSHOT_SOURCES: usize = 1024;
34/// Maximum number of hypothesis records in one snapshot.
35pub const MAX_RECONNAISSANCE_SNAPSHOT_RECORDS: usize = 1024;
36/// Maximum total record-to-source associations in one snapshot.
37pub const MAX_RECONNAISSANCE_SNAPSHOT_SOURCE_ASSOCIATIONS: usize = 4096;
38/// Maximum conservative allocation attributed to prepared lookup indexes.
39pub const MAX_RECONNAISSANCE_SNAPSHOT_PREPARED_INDEX_BYTES: usize = 4 * 1024 * 1024;
40
41const MAX_IDENTIFIER_BYTES: usize = 128;
42const MAX_TIMESTAMP_BYTES: usize = 96;
43const MAX_ORIGIN_BYTES: usize = 2048;
44const MAX_RIGHTS_TEXT_BYTES: usize = 4096;
45const MAX_PRODUCT_BYTES: usize = 256;
46const MAX_VERSION_BYTES: usize = 128;
47const MAX_LABEL_BYTES: usize = 256;
48const MAX_DNS_NAME_BYTES: usize = 253;
49const MAX_JSON_DEPTH: usize = 16;
50const MAX_JSON_NODES: usize = 65_536;
51const MAX_JSON_MEMBERS: usize = 32_768;
52const MAX_JSON_ARRAY_ITEMS: usize = 8192;
53const MAX_JSON_KEY_BYTES: usize = 128;
54const MAX_JSON_STRING_BYTES: usize = 4096;
55const CONSERVATIVE_BTREE_ENTRY_OVERHEAD: usize = 96;
56const DUPLICATE_KEY_MARKER: &str = "termivar_recon_snapshot_duplicate_key";
57const JSON_LIMIT_MARKER: &str = "termivar_recon_snapshot_json_limit";
58
59/// Exact identity of the caller-supplied JSON bytes.
60///
61/// This is deliberately separate from every operator- or provider-declared
62/// identifier in the snapshot. It authenticates neither those declarations
63/// nor the party that supplied the bytes.
64#[derive(Clone, Debug, Eq, PartialEq)]
65pub struct ReconnaissanceSnapshotInputIdentity {
66    byte_length: u64,
67    sha256: [u8; 32],
68}
69
70impl ReconnaissanceSnapshotInputIdentity {
71    /// Returns the length of the exact JSON input.
72    #[must_use]
73    pub const fn byte_length(&self) -> u64 {
74        self.byte_length
75    }
76
77    /// Returns SHA-256 over the exact JSON input, including layout and order.
78    #[must_use]
79    pub const fn sha256(&self) -> &[u8; 32] {
80        &self.sha256
81    }
82
83    /// Returns the exact-input digest as lowercase hexadecimal.
84    #[must_use]
85    pub fn sha256_hex(&self) -> String {
86        hex_digest(self.sha256)
87    }
88}
89
90/// Fixed interpretation limit attached to every imported record.
91#[derive(Clone, Copy, Debug, Eq, PartialEq)]
92pub enum ReconnaissanceClaimLimit {
93    /// Values remain source-qualified hypotheses and are not findings or
94    /// independently verified claims.
95    SourceQualifiedHypothesesOnly,
96}
97
98impl ReconnaissanceClaimLimit {
99    /// Returns the stable reporting token.
100    #[must_use]
101    pub const fn as_str(self) -> &'static str {
102        match self {
103            Self::SourceQualifiedHypothesesOnly => "source_qualified_hypotheses_only",
104        }
105    }
106}
107
108/// Closed status for external work excluded from this offline parser.
109#[derive(Clone, Copy, Debug, Eq, PartialEq)]
110pub enum ReconnaissanceExternalOperationStatus {
111    /// The operation was not performed.
112    NotPerformed,
113}
114
115impl ReconnaissanceExternalOperationStatus {
116    /// Returns the stable reporting token.
117    #[must_use]
118    pub const fn as_str(self) -> &'static str {
119        match self {
120            Self::NotPerformed => "not_performed",
121        }
122    }
123}
124
125/// Proof-of-scope facts for the transport-free, archive-free import.
126#[derive(Clone, Copy, Debug, Eq, PartialEq)]
127pub struct ReconnaissanceExternalActivity;
128
129impl ReconnaissanceExternalActivity {
130    /// Target requests made by this parser; fixed at zero.
131    #[must_use]
132    pub const fn target_request_count(self) -> u8 {
133        0
134    }
135
136    /// Provider requests made by this parser; fixed at zero.
137    #[must_use]
138    pub const fn provider_request_count(self) -> u8 {
139        0
140    }
141
142    /// Archive handling is outside the accepted input contract.
143    #[must_use]
144    pub const fn archive_processing(self) -> ReconnaissanceExternalOperationStatus {
145        ReconnaissanceExternalOperationStatus::NotPerformed
146    }
147
148    /// Decompression is outside the accepted input contract.
149    #[must_use]
150    pub const fn decompression(self) -> ReconnaissanceExternalOperationStatus {
151        ReconnaissanceExternalOperationStatus::NotPerformed
152    }
153}
154
155/// Value-free audit for one successful strict import.
156#[derive(Clone, Debug, Eq, PartialEq)]
157pub struct ReconnaissanceSnapshotAudit {
158    input: ReconnaissanceSnapshotInputIdentity,
159    source_count: usize,
160    record_count: usize,
161    source_association_count: usize,
162    prepared_index_bytes: usize,
163}
164
165impl ReconnaissanceSnapshotAudit {
166    /// Returns the accepted wire schema.
167    #[must_use]
168    pub const fn schema(&self) -> &'static str {
169        RECONNAISSANCE_SNAPSHOT_SCHEMA
170    }
171
172    /// Returns the parser and interpretation policy.
173    #[must_use]
174    pub const fn policy(&self) -> &'static str {
175        RECONNAISSANCE_SNAPSHOT_POLICY
176    }
177
178    /// Returns the exact-input identity, distinct from declared provenance.
179    #[must_use]
180    pub const fn input(&self) -> &ReconnaissanceSnapshotInputIdentity {
181        &self.input
182    }
183
184    /// Returns the exact input byte length.
185    #[must_use]
186    pub const fn input_byte_length(&self) -> u64 {
187        self.input.byte_length
188    }
189
190    /// Returns SHA-256 over the exact input bytes.
191    #[must_use]
192    pub const fn input_sha256(&self) -> &[u8; 32] {
193        &self.input.sha256
194    }
195
196    /// Returns the number of declared sources retained by the import.
197    #[must_use]
198    pub const fn source_count(&self) -> usize {
199        self.source_count
200    }
201
202    /// Returns the number of source-qualified hypothesis records.
203    #[must_use]
204    pub const fn record_count(&self) -> usize {
205        self.record_count
206    }
207
208    /// Returns the total number of resolved record-to-source associations.
209    #[must_use]
210    pub const fn source_association_count(&self) -> usize {
211        self.source_association_count
212    }
213
214    /// Returns conservative bytes charged to the prepared lookup indexes.
215    #[must_use]
216    pub const fn prepared_index_bytes(&self) -> usize {
217        self.prepared_index_bytes
218    }
219
220    /// Returns the fixed no-network/no-archive activity facts.
221    #[must_use]
222    pub const fn external_activity(&self) -> ReconnaissanceExternalActivity {
223        ReconnaissanceExternalActivity
224    }
225
226    /// Returns the fixed hypothesis-only interpretation boundary.
227    #[must_use]
228    pub const fn claim_limit(&self) -> ReconnaissanceClaimLimit {
229        ReconnaissanceClaimLimit::SourceQualifiedHypothesesOnly
230    }
231}
232
233/// Completeness asserted by a source declaration.
234///
235/// No variant is independently verified by this parser.
236#[derive(Clone, Copy, Debug, Eq, PartialEq)]
237pub enum ReconnaissanceCompleteness {
238    /// The source declares that the supplied material is complete for its own
239    /// unspecified provider scope.
240    CompleteAsDeclared,
241    /// The source declares partial coverage.
242    PartialAsDeclared,
243    /// Completeness was declared unknown.
244    Unknown,
245}
246
247impl ReconnaissanceCompleteness {
248    /// Returns the V1 wire token.
249    #[must_use]
250    pub const fn as_str(self) -> &'static str {
251        match self {
252            Self::CompleteAsDeclared => "complete_as_declared",
253            Self::PartialAsDeclared => "partial_as_declared",
254            Self::Unknown => "unknown",
255        }
256    }
257}
258
259/// Rights status asserted by the snapshot source declaration.
260///
261/// The parser preserves this statement as inert metadata; it does not verify
262/// ownership, licensing, permission, attribution, or redistribution rights.
263#[derive(Clone, Copy, Debug, Eq, PartialEq)]
264pub enum ReconnaissanceDeclaredRightsStatus {
265    /// The snapshot declares use to be permitted.
266    PermittedAsDeclared,
267    /// The snapshot declares some restriction.
268    RestrictedAsDeclared,
269    /// The snapshot declares no known rights status.
270    Unknown,
271}
272
273impl ReconnaissanceDeclaredRightsStatus {
274    /// Returns the V1 wire token.
275    #[must_use]
276    pub const fn as_str(self) -> &'static str {
277        match self {
278            Self::PermittedAsDeclared => "permitted_as_declared",
279            Self::RestrictedAsDeclared => "restricted_as_declared",
280            Self::Unknown => "unknown",
281        }
282    }
283}
284
285/// Inert attribution and notice text supplied by a source declaration.
286#[derive(Clone, Debug, Eq, PartialEq)]
287pub struct ReconnaissanceDeclaredRights {
288    status: ReconnaissanceDeclaredRightsStatus,
289    attribution: Option<String>,
290    notice: Option<String>,
291}
292
293impl ReconnaissanceDeclaredRights {
294    /// Returns the unverified declared rights status.
295    #[must_use]
296    pub const fn status(&self) -> ReconnaissanceDeclaredRightsStatus {
297        self.status
298    }
299
300    /// Returns inert declared attribution text, if supplied.
301    #[must_use]
302    pub fn attribution(&self) -> Option<&str> {
303        self.attribution.as_deref()
304    }
305
306    /// Returns inert declared rights notice text, if supplied.
307    #[must_use]
308    pub fn notice(&self) -> Option<&str> {
309        self.notice.as_deref()
310    }
311}
312
313/// One unverified source declaration in a finite local snapshot.
314#[derive(Clone, Debug, Eq, PartialEq)]
315pub struct ReconnaissanceSource {
316    source_id: String,
317    namespace: String,
318    revision: String,
319    provider_time: Option<String>,
320    observed_time: String,
321    collection_method: String,
322    completeness: ReconnaissanceCompleteness,
323    origin: String,
324    rights: ReconnaissanceDeclaredRights,
325}
326
327impl ReconnaissanceSource {
328    /// Returns the snapshot-local declared source identifier.
329    #[must_use]
330    pub fn source_id(&self) -> &str {
331        &self.source_id
332    }
333
334    /// Returns the declared provider namespace.
335    #[must_use]
336    pub fn namespace(&self) -> &str {
337        &self.namespace
338    }
339
340    /// Returns the declared provider revision.
341    #[must_use]
342    pub fn revision(&self) -> &str {
343        &self.revision
344    }
345
346    /// Returns the inert provider-time declaration, if one was supplied.
347    #[must_use]
348    pub fn provider_time(&self) -> Option<&str> {
349        self.provider_time.as_deref()
350    }
351
352    /// Returns the inert observation-time declaration.
353    #[must_use]
354    pub fn observed_time(&self) -> &str {
355        &self.observed_time
356    }
357
358    /// Returns the declared collection method token.
359    #[must_use]
360    pub fn collection_method(&self) -> &str {
361        &self.collection_method
362    }
363
364    /// Returns the unverified declared completeness.
365    #[must_use]
366    pub const fn completeness(&self) -> ReconnaissanceCompleteness {
367        self.completeness
368    }
369
370    /// Returns the inert declared source origin; it grants no network authority.
371    #[must_use]
372    pub fn origin(&self) -> &str {
373        &self.origin
374    }
375
376    /// Returns the unverified declared rights metadata.
377    #[must_use]
378    pub const fn rights(&self) -> &ReconnaissanceDeclaredRights {
379        &self.rights
380    }
381}
382
383/// Transport named by an inert service-banner product hypothesis.
384#[derive(Clone, Copy, Debug, Eq, PartialEq)]
385pub enum ReconnaissanceTransport {
386    /// Transmission Control Protocol.
387    Tcp,
388    /// User Datagram Protocol.
389    Udp,
390}
391
392impl ReconnaissanceTransport {
393    /// Returns the V1 wire token.
394    #[must_use]
395    pub const fn as_str(self) -> &'static str {
396        match self {
397            Self::Tcp => "tcp",
398            Self::Udp => "udp",
399        }
400    }
401}
402
403/// Closed kind of source-qualified V1 hypothesis record.
404#[derive(Clone, Copy, Debug, Eq, PartialEq)]
405pub enum ReconnaissanceRecordKind {
406    /// A name listed by certificate-transparency-derived material.
407    CtName,
408    /// A historical DNS name/address association.
409    DnsHistory,
410    /// A product hint derived from a service banner.
411    ServiceBannerProduct,
412    /// A label assigned by a reputation source.
413    ReputationLabel,
414}
415
416impl ReconnaissanceRecordKind {
417    /// Returns the V1 wire token.
418    #[must_use]
419    pub const fn as_str(self) -> &'static str {
420        match self {
421            Self::CtName => "ct_name",
422            Self::DnsHistory => "dns_history",
423            Self::ServiceBannerProduct => "service_banner_product",
424            Self::ReputationLabel => "reputation_label",
425        }
426    }
427}
428
429/// A certificate-transparency-name hypothesis value.
430#[derive(Clone, Debug, Eq, PartialEq)]
431pub struct CtNameHypothesis {
432    name: String,
433}
434
435impl CtNameHypothesis {
436    /// Returns the bounded ASCII DNS name, optionally beginning with `*.`.
437    #[must_use]
438    pub fn name(&self) -> &str {
439        &self.name
440    }
441}
442
443/// A historical DNS association hypothesis value.
444#[derive(Clone, Debug, Eq, PartialEq)]
445pub struct DnsHistoryHypothesis {
446    name: String,
447    address: IpAddr,
448}
449
450impl DnsHistoryHypothesis {
451    /// Returns the bounded ASCII DNS name.
452    #[must_use]
453    pub fn name(&self) -> &str {
454        &self.name
455    }
456
457    /// Returns the syntactically parsed IP address.
458    #[must_use]
459    pub const fn address(&self) -> IpAddr {
460        self.address
461    }
462}
463
464/// An inert service-banner product hypothesis value.
465#[derive(Clone, Debug, Eq, PartialEq)]
466pub struct ServiceBannerProductHypothesis {
467    host: String,
468    port: u16,
469    transport: ReconnaissanceTransport,
470    product: String,
471    version: Option<String>,
472}
473
474impl ServiceBannerProductHypothesis {
475    /// Returns the validated ASCII DNS name or IP address text.
476    #[must_use]
477    pub fn host(&self) -> &str {
478        &self.host
479    }
480
481    /// Returns the nonzero declared service port.
482    #[must_use]
483    pub const fn port(&self) -> u16 {
484        self.port
485    }
486
487    /// Returns the closed declared transport.
488    #[must_use]
489    pub const fn transport(&self) -> ReconnaissanceTransport {
490        self.transport
491    }
492
493    /// Returns the inert product hint.
494    #[must_use]
495    pub fn product(&self) -> &str {
496        &self.product
497    }
498
499    /// Returns the inert optional version hint.
500    #[must_use]
501    pub fn version(&self) -> Option<&str> {
502        self.version.as_deref()
503    }
504}
505
506/// An inert reputation-label hypothesis value.
507#[derive(Clone, Debug, Eq, PartialEq)]
508pub struct ReputationLabelHypothesis {
509    subject: String,
510    label: String,
511}
512
513impl ReputationLabelHypothesis {
514    /// Returns the validated ASCII DNS name or IP address text.
515    #[must_use]
516    pub fn subject(&self) -> &str {
517        &self.subject
518    }
519
520    /// Returns the source-supplied label without interpreting its meaning.
521    #[must_use]
522    pub fn label(&self) -> &str {
523        &self.label
524    }
525}
526
527/// Closed value carried by a source-qualified V1 hypothesis record.
528#[derive(Clone, Debug, Eq, PartialEq)]
529pub enum ReconnaissanceRecordValue {
530    /// Certificate-transparency-derived name.
531    CtName(CtNameHypothesis),
532    /// Historical DNS name/address association.
533    DnsHistory(DnsHistoryHypothesis),
534    /// Service-banner-derived product hint.
535    ServiceBannerProduct(ServiceBannerProductHypothesis),
536    /// Source-supplied reputation label.
537    ReputationLabel(ReputationLabelHypothesis),
538}
539
540impl ReconnaissanceRecordValue {
541    /// Returns the closed record kind.
542    #[must_use]
543    pub const fn kind(&self) -> ReconnaissanceRecordKind {
544        match self {
545            Self::CtName(_) => ReconnaissanceRecordKind::CtName,
546            Self::DnsHistory(_) => ReconnaissanceRecordKind::DnsHistory,
547            Self::ServiceBannerProduct(_) => ReconnaissanceRecordKind::ServiceBannerProduct,
548            Self::ReputationLabel(_) => ReconnaissanceRecordKind::ReputationLabel,
549        }
550    }
551}
552
553/// One bounded, source-qualified hypothesis record.
554///
555/// Source references are mandatory and resolved during parsing. This type has
556/// no finding, verification, exploitability, target, or request authority.
557#[derive(Clone, Debug, Eq, PartialEq)]
558pub struct ReconnaissanceRecord {
559    record_id: String,
560    source_ids: Vec<String>,
561    source_indices: Vec<usize>,
562    value: ReconnaissanceRecordValue,
563}
564
565/// Explicit alias emphasizing the interpretation of every returned record.
566pub type ReconnaissanceHypothesis = ReconnaissanceRecord;
567
568impl ReconnaissanceRecord {
569    /// Returns the snapshot-local record identifier.
570    #[must_use]
571    pub fn record_id(&self) -> &str {
572        &self.record_id
573    }
574
575    /// Returns the nonempty, unique, resolved declared source identifiers.
576    #[must_use]
577    pub fn source_ids(&self) -> &[String] {
578        &self.source_ids
579    }
580
581    /// Returns the closed record kind.
582    #[must_use]
583    pub const fn kind(&self) -> ReconnaissanceRecordKind {
584        self.value.kind()
585    }
586
587    /// Returns the typed hypothesis value.
588    #[must_use]
589    pub const fn value(&self) -> &ReconnaissanceRecordValue {
590        &self.value
591    }
592
593    /// Returns the fixed hypothesis-only interpretation boundary.
594    #[must_use]
595    pub const fn claim_limit(&self) -> ReconnaissanceClaimLimit {
596        ReconnaissanceClaimLimit::SourceQualifiedHypothesesOnly
597    }
598}
599
600#[derive(Clone, Debug, Eq, PartialEq)]
601struct PreparedIndex {
602    sources: BTreeMap<String, usize>,
603    records: BTreeMap<String, usize>,
604}
605
606/// One validated finite snapshot plus deterministic lookup indexes.
607#[derive(Clone, Debug, Eq, PartialEq)]
608pub struct ReconnaissanceSnapshot {
609    snapshot_id: String,
610    snapshot_revision: String,
611    sources: Vec<ReconnaissanceSource>,
612    records: Vec<ReconnaissanceRecord>,
613    prepared_index: PreparedIndex,
614    audit: ReconnaissanceSnapshotAudit,
615}
616
617/// Concise integration alias for a validated offline snapshot.
618pub type ReconSnapshot = ReconnaissanceSnapshot;
619
620/// Concise integration alias for strict snapshot import failures.
621pub type ReconSnapshotError = ReconnaissanceSnapshotError;
622
623impl ReconnaissanceSnapshot {
624    /// Parses one complete strict V1 JSON document from exact local bytes.
625    pub fn parse_json(bytes: &[u8]) -> Result<Self, ReconnaissanceSnapshotError> {
626        parse_with_limits(
627            bytes,
628            MAX_RECONNAISSANCE_SNAPSHOT_INPUT_BYTES,
629            MAX_RECONNAISSANCE_SNAPSHOT_PREPARED_INDEX_BYTES,
630        )
631    }
632
633    /// Returns the accepted schema identifier.
634    #[must_use]
635    pub const fn schema(&self) -> &'static str {
636        RECONNAISSANCE_SNAPSHOT_SCHEMA
637    }
638
639    /// Returns the stable parser and interpretation policy.
640    #[must_use]
641    pub const fn policy(&self) -> &'static str {
642        RECONNAISSANCE_SNAPSHOT_POLICY
643    }
644
645    /// Returns the operator-declared snapshot identifier.
646    #[must_use]
647    pub fn snapshot_id(&self) -> &str {
648        &self.snapshot_id
649    }
650
651    /// Returns the operator-declared snapshot revision.
652    #[must_use]
653    pub fn snapshot_revision(&self) -> &str {
654        &self.snapshot_revision
655    }
656
657    /// Returns the value-free strict-import audit.
658    #[must_use]
659    pub const fn audit(&self) -> &ReconnaissanceSnapshotAudit {
660        &self.audit
661    }
662
663    /// Returns the exact-input identity.
664    #[must_use]
665    pub const fn input(&self) -> &ReconnaissanceSnapshotInputIdentity {
666        &self.audit.input
667    }
668
669    /// Returns unverified source declarations in input order.
670    #[must_use]
671    pub fn sources(&self) -> &[ReconnaissanceSource] {
672        &self.sources
673    }
674
675    /// Returns source-qualified hypothesis records in input order.
676    #[must_use]
677    pub fn records(&self) -> &[ReconnaissanceRecord] {
678        &self.records
679    }
680
681    /// Returns the same records through their explicit hypothesis alias.
682    #[must_use]
683    pub fn hypotheses(&self) -> &[ReconnaissanceHypothesis] {
684        &self.records
685    }
686
687    /// Looks up one declared source by its exact snapshot-local identifier.
688    #[must_use]
689    pub fn source(&self, source_id: &str) -> Option<&ReconnaissanceSource> {
690        self.prepared_index
691            .sources
692            .get(source_id)
693            .map(|&index| &self.sources[index])
694    }
695
696    /// Looks up one hypothesis record by its exact snapshot-local identifier.
697    #[must_use]
698    pub fn record(&self, record_id: &str) -> Option<&ReconnaissanceRecord> {
699        self.prepared_index
700            .records
701            .get(record_id)
702            .map(|&index| &self.records[index])
703    }
704
705    /// Returns the number of declared sources.
706    #[must_use]
707    pub const fn source_count(&self) -> usize {
708        self.audit.source_count
709    }
710
711    /// Returns the number of hypothesis records.
712    #[must_use]
713    pub const fn record_count(&self) -> usize {
714        self.audit.record_count
715    }
716
717    /// Returns the total number of resolved record-to-source associations.
718    #[must_use]
719    pub const fn source_association_count(&self) -> usize {
720        self.audit.source_association_count
721    }
722
723    /// Returns conservative bytes charged to the prepared lookup indexes.
724    #[must_use]
725    pub const fn prepared_index_bytes(&self) -> usize {
726        self.audit.prepared_index_bytes
727    }
728
729    /// Returns the fixed no-network/no-archive activity facts.
730    #[must_use]
731    pub const fn external_activity(&self) -> ReconnaissanceExternalActivity {
732        ReconnaissanceExternalActivity
733    }
734
735    /// Returns the fixed hypothesis-only interpretation boundary.
736    #[must_use]
737    pub const fn claim_limit(&self) -> ReconnaissanceClaimLimit {
738        ReconnaissanceClaimLimit::SourceQualifiedHypothesesOnly
739    }
740}
741
742/// Parses one complete strict V1 JSON document from exact local bytes.
743pub fn parse_reconnaissance_snapshot(
744    bytes: &[u8],
745) -> Result<ReconnaissanceSnapshot, ReconnaissanceSnapshotError> {
746    ReconnaissanceSnapshot::parse_json(bytes)
747}
748
749/// Parses one complete strict V1 JSON document using the concise integration
750/// names retained by assessment and reporting surfaces.
751pub fn parse_recon_snapshot(bytes: &[u8]) -> Result<ReconSnapshot, ReconSnapshotError> {
752    parse_reconnaissance_snapshot(bytes)
753}
754
755/// Static, value-free failure from strict reconnaissance snapshot import.
756#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
757#[non_exhaustive]
758pub enum ReconnaissanceSnapshotError {
759    /// No exact input bytes were supplied.
760    #[error("reconnaissance snapshot input is empty")]
761    EmptyInput,
762    /// The exact input exceeded the one-MiB ceiling.
763    #[error("reconnaissance snapshot input exceeds its byte limit")]
764    InputTooLarge,
765    /// The input was not one complete JSON value.
766    #[error("reconnaissance snapshot input is malformed JSON")]
767    MalformedJson,
768    /// A JSON object contained a duplicate decoded key.
769    #[error("reconnaissance snapshot input contains a duplicate JSON key")]
770    DuplicateJsonKey,
771    /// JSON depth, nodes, members, arrays, keys, or strings exceeded a bound.
772    #[error("reconnaissance snapshot input exceeds a structural limit")]
773    StructuralLimitExceeded,
774    /// The document did not select the exact V1 schema.
775    #[error("reconnaissance snapshot schema is unsupported")]
776    UnsupportedSchema,
777    /// A required field, strict type, closed value, or bounded scalar was invalid.
778    #[error("reconnaissance snapshot contains an invalid or unsupported value")]
779    InvalidSnapshot,
780    /// More source declarations were supplied than V1 permits.
781    #[error("reconnaissance snapshot contains too many sources")]
782    TooManySources,
783    /// Two source declarations used the same source identifier.
784    #[error("reconnaissance snapshot contains a duplicate source identifier")]
785    DuplicateSourceId,
786    /// More hypothesis records were supplied than V1 permits.
787    #[error("reconnaissance snapshot contains too many records")]
788    TooManyRecords,
789    /// Two hypothesis records used the same record identifier.
790    #[error("reconnaissance snapshot contains a duplicate record identifier")]
791    DuplicateRecordId,
792    /// One record repeated a source identifier.
793    #[error("reconnaissance snapshot record contains a duplicate source reference")]
794    DuplicateRecordSource,
795    /// One record referred to a source declaration that does not exist.
796    #[error("reconnaissance snapshot record contains an unresolved source reference")]
797    UnknownRecordSource,
798    /// Total record-to-source associations exceeded the V1 ceiling.
799    #[error("reconnaissance snapshot contains too many source associations")]
800    TooManySourceAssociations,
801    /// Prepared deterministic lookup state exceeded its memory ceiling.
802    #[error("reconnaissance snapshot prepared index exceeds its byte limit")]
803    PreparedIndexTooLarge,
804}
805
806#[derive(Deserialize)]
807#[serde(deny_unknown_fields)]
808struct SnapshotWire {
809    schema: String,
810    snapshot_id: String,
811    snapshot_revision: String,
812    sources: Vec<SourceWire>,
813    records: Vec<RecordWire>,
814}
815
816#[derive(Deserialize)]
817#[serde(deny_unknown_fields)]
818struct SourceWire {
819    source_id: String,
820    namespace: String,
821    revision: String,
822    #[serde(deserialize_with = "deserialize_required_nullable")]
823    provider_time: RequiredNullable<String>,
824    observed_time: String,
825    collection_method: String,
826    completeness: CompletenessWire,
827    origin: String,
828    rights: RightsWire,
829}
830
831#[derive(Deserialize)]
832#[serde(deny_unknown_fields)]
833struct RightsWire {
834    status: RightsStatusWire,
835    #[serde(deserialize_with = "deserialize_required_nullable")]
836    attribution: RequiredNullable<String>,
837    #[serde(deserialize_with = "deserialize_required_nullable")]
838    notice: RequiredNullable<String>,
839}
840
841#[derive(Clone, Copy, Deserialize)]
842#[serde(rename_all = "snake_case")]
843enum CompletenessWire {
844    CompleteAsDeclared,
845    PartialAsDeclared,
846    Unknown,
847}
848
849impl From<CompletenessWire> for ReconnaissanceCompleteness {
850    fn from(value: CompletenessWire) -> Self {
851        match value {
852            CompletenessWire::CompleteAsDeclared => Self::CompleteAsDeclared,
853            CompletenessWire::PartialAsDeclared => Self::PartialAsDeclared,
854            CompletenessWire::Unknown => Self::Unknown,
855        }
856    }
857}
858
859#[derive(Clone, Copy, Deserialize)]
860#[serde(rename_all = "snake_case")]
861enum RightsStatusWire {
862    PermittedAsDeclared,
863    RestrictedAsDeclared,
864    Unknown,
865}
866
867impl From<RightsStatusWire> for ReconnaissanceDeclaredRightsStatus {
868    fn from(value: RightsStatusWire) -> Self {
869        match value {
870            RightsStatusWire::PermittedAsDeclared => Self::PermittedAsDeclared,
871            RightsStatusWire::RestrictedAsDeclared => Self::RestrictedAsDeclared,
872            RightsStatusWire::Unknown => Self::Unknown,
873        }
874    }
875}
876
877#[derive(Deserialize)]
878#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
879enum RecordWire {
880    CtName {
881        record_id: String,
882        source_ids: Vec<String>,
883        name: String,
884    },
885    DnsHistory {
886        record_id: String,
887        source_ids: Vec<String>,
888        name: String,
889        address: String,
890    },
891    ServiceBannerProduct {
892        record_id: String,
893        source_ids: Vec<String>,
894        host: String,
895        port: u16,
896        transport: TransportWire,
897        product: String,
898        #[serde(deserialize_with = "deserialize_required_nullable")]
899        version: RequiredNullable<String>,
900    },
901    ReputationLabel {
902        record_id: String,
903        source_ids: Vec<String>,
904        subject: String,
905        label: String,
906    },
907}
908
909#[derive(Clone, Copy, Deserialize)]
910#[serde(rename_all = "snake_case")]
911enum TransportWire {
912    Tcp,
913    Udp,
914}
915
916impl From<TransportWire> for ReconnaissanceTransport {
917    fn from(value: TransportWire) -> Self {
918        match value {
919            TransportWire::Tcp => Self::Tcp,
920            TransportWire::Udp => Self::Udp,
921        }
922    }
923}
924
925struct RequiredNullable<T>(Option<T>);
926
927fn deserialize_required_nullable<'de, D, T>(
928    deserializer: D,
929) -> Result<RequiredNullable<T>, D::Error>
930where
931    T: Deserialize<'de>,
932    D: Deserializer<'de>,
933{
934    Option::<T>::deserialize(deserializer).map(RequiredNullable)
935}
936
937struct RecordParts {
938    record_id: String,
939    source_ids: Vec<String>,
940    value: ReconnaissanceRecordValue,
941}
942
943impl RecordWire {
944    fn validate(self) -> Result<RecordParts, ReconnaissanceSnapshotError> {
945        match self {
946            Self::CtName {
947                record_id,
948                source_ids,
949                name,
950            } => {
951                if !valid_dns_name(&name, true) {
952                    return Err(ReconnaissanceSnapshotError::InvalidSnapshot);
953                }
954                Ok(RecordParts {
955                    record_id,
956                    source_ids,
957                    value: ReconnaissanceRecordValue::CtName(CtNameHypothesis {
958                        name: tight_string(name),
959                    }),
960                })
961            },
962            Self::DnsHistory {
963                record_id,
964                source_ids,
965                name,
966                address,
967            } => {
968                if !valid_dns_name(&name, false) {
969                    return Err(ReconnaissanceSnapshotError::InvalidSnapshot);
970                }
971                let address = address
972                    .parse::<IpAddr>()
973                    .map_err(|_| ReconnaissanceSnapshotError::InvalidSnapshot)?;
974                Ok(RecordParts {
975                    record_id,
976                    source_ids,
977                    value: ReconnaissanceRecordValue::DnsHistory(DnsHistoryHypothesis {
978                        name: tight_string(name),
979                        address,
980                    }),
981                })
982            },
983            Self::ServiceBannerProduct {
984                record_id,
985                source_ids,
986                host,
987                port,
988                transport,
989                product,
990                version,
991            } => {
992                if !valid_host(&host)
993                    || port == 0
994                    || !valid_bounded_text(&product, MAX_PRODUCT_BYTES)
995                    || version
996                        .0
997                        .as_deref()
998                        .is_some_and(|value| !valid_bounded_text(value, MAX_VERSION_BYTES))
999                {
1000                    return Err(ReconnaissanceSnapshotError::InvalidSnapshot);
1001                }
1002                Ok(RecordParts {
1003                    record_id,
1004                    source_ids,
1005                    value: ReconnaissanceRecordValue::ServiceBannerProduct(
1006                        ServiceBannerProductHypothesis {
1007                            host: tight_string(host),
1008                            port,
1009                            transport: transport.into(),
1010                            product: tight_string(product),
1011                            version: version.0.map(tight_string),
1012                        },
1013                    ),
1014                })
1015            },
1016            Self::ReputationLabel {
1017                record_id,
1018                source_ids,
1019                subject,
1020                label,
1021            } => {
1022                if !valid_host(&subject) || !valid_bounded_text(&label, MAX_LABEL_BYTES) {
1023                    return Err(ReconnaissanceSnapshotError::InvalidSnapshot);
1024                }
1025                Ok(RecordParts {
1026                    record_id,
1027                    source_ids,
1028                    value: ReconnaissanceRecordValue::ReputationLabel(ReputationLabelHypothesis {
1029                        subject: tight_string(subject),
1030                        label: tight_string(label),
1031                    }),
1032                })
1033            },
1034        }
1035    }
1036}
1037
1038fn parse_with_limits(
1039    bytes: &[u8],
1040    maximum_input_bytes: usize,
1041    maximum_prepared_index_bytes: usize,
1042) -> Result<ReconnaissanceSnapshot, ReconnaissanceSnapshotError> {
1043    if bytes.is_empty() {
1044        return Err(ReconnaissanceSnapshotError::EmptyInput);
1045    }
1046    if bytes.len() > maximum_input_bytes {
1047        return Err(ReconnaissanceSnapshotError::InputTooLarge);
1048    }
1049
1050    let value = parse_strict_json(bytes)?;
1051    let wire: SnapshotWire =
1052        serde_json::from_value(value).map_err(|_| ReconnaissanceSnapshotError::InvalidSnapshot)?;
1053    if wire.schema != RECONNAISSANCE_SNAPSHOT_SCHEMA {
1054        return Err(ReconnaissanceSnapshotError::UnsupportedSchema);
1055    }
1056    if !valid_identifier(&wire.snapshot_id) || !valid_identifier(&wire.snapshot_revision) {
1057        return Err(ReconnaissanceSnapshotError::InvalidSnapshot);
1058    }
1059    if wire.sources.is_empty() {
1060        return Err(ReconnaissanceSnapshotError::InvalidSnapshot);
1061    }
1062    if wire.sources.len() > MAX_RECONNAISSANCE_SNAPSHOT_SOURCES {
1063        return Err(ReconnaissanceSnapshotError::TooManySources);
1064    }
1065    if wire.records.len() > MAX_RECONNAISSANCE_SNAPSHOT_RECORDS {
1066        return Err(ReconnaissanceSnapshotError::TooManyRecords);
1067    }
1068
1069    let mut index_budget = PreparedIndexBudget::new(maximum_prepared_index_bytes)?;
1070    let mut source_index = BTreeMap::new();
1071    let mut sources = Vec::with_capacity(wire.sources.len());
1072    for source in wire.sources {
1073        let source = validate_source(source)?;
1074        if source_index.contains_key(source.source_id()) {
1075            return Err(ReconnaissanceSnapshotError::DuplicateSourceId);
1076        }
1077        index_budget.add_map_entry(source.source_id())?;
1078        source_index.insert(source.source_id.clone(), sources.len());
1079        sources.push(source);
1080    }
1081
1082    let mut record_index = BTreeMap::new();
1083    let mut records = Vec::with_capacity(wire.records.len());
1084    let mut source_association_count = 0_usize;
1085    for record in wire.records {
1086        let parts = record.validate()?;
1087        if !valid_identifier(&parts.record_id) {
1088            return Err(ReconnaissanceSnapshotError::InvalidSnapshot);
1089        }
1090        if record_index.contains_key(&parts.record_id) {
1091            return Err(ReconnaissanceSnapshotError::DuplicateRecordId);
1092        }
1093        if parts.source_ids.is_empty() {
1094            return Err(ReconnaissanceSnapshotError::InvalidSnapshot);
1095        }
1096        source_association_count = source_association_count
1097            .checked_add(parts.source_ids.len())
1098            .filter(|count| *count <= MAX_RECONNAISSANCE_SNAPSHOT_SOURCE_ASSOCIATIONS)
1099            .ok_or(ReconnaissanceSnapshotError::TooManySourceAssociations)?;
1100
1101        let mut unique_sources = BTreeSet::new();
1102        let mut source_ids = Vec::with_capacity(parts.source_ids.len());
1103        let mut source_indices = Vec::with_capacity(parts.source_ids.len());
1104        for source_id in parts.source_ids {
1105            if !valid_identifier(&source_id) {
1106                return Err(ReconnaissanceSnapshotError::InvalidSnapshot);
1107            }
1108            if !unique_sources.insert(source_id.clone()) {
1109                return Err(ReconnaissanceSnapshotError::DuplicateRecordSource);
1110            }
1111            let source_index = source_index
1112                .get(&source_id)
1113                .copied()
1114                .ok_or(ReconnaissanceSnapshotError::UnknownRecordSource)?;
1115            source_ids.push(tight_string(source_id));
1116            source_indices.push(source_index);
1117        }
1118        index_budget.add_map_entry(&parts.record_id)?;
1119        index_budget.add_resolved_sources(source_indices.capacity())?;
1120        record_index.insert(parts.record_id.clone(), records.len());
1121        records.push(ReconnaissanceRecord {
1122            record_id: tight_string(parts.record_id),
1123            source_ids,
1124            source_indices,
1125            value: parts.value,
1126        });
1127    }
1128
1129    sources.shrink_to_fit();
1130    records.shrink_to_fit();
1131    let byte_length =
1132        u64::try_from(bytes.len()).map_err(|_| ReconnaissanceSnapshotError::InputTooLarge)?;
1133    let input = ReconnaissanceSnapshotInputIdentity {
1134        byte_length,
1135        sha256: Sha256::digest(bytes).into(),
1136    };
1137    let audit = ReconnaissanceSnapshotAudit {
1138        input,
1139        source_count: sources.len(),
1140        record_count: records.len(),
1141        source_association_count,
1142        prepared_index_bytes: index_budget.used(),
1143    };
1144    Ok(ReconnaissanceSnapshot {
1145        snapshot_id: tight_string(wire.snapshot_id),
1146        snapshot_revision: tight_string(wire.snapshot_revision),
1147        sources,
1148        records,
1149        prepared_index: PreparedIndex {
1150            sources: source_index,
1151            records: record_index,
1152        },
1153        audit,
1154    })
1155}
1156
1157fn validate_source(wire: SourceWire) -> Result<ReconnaissanceSource, ReconnaissanceSnapshotError> {
1158    if !valid_identifier(&wire.source_id)
1159        || !valid_identifier(&wire.namespace)
1160        || !valid_identifier(&wire.revision)
1161        || wire
1162            .provider_time
1163            .0
1164            .as_deref()
1165            .is_some_and(|value| !valid_ascii_text(value, MAX_TIMESTAMP_BYTES))
1166        || !valid_ascii_text(&wire.observed_time, MAX_TIMESTAMP_BYTES)
1167        || !valid_identifier(&wire.collection_method)
1168        || !valid_bounded_text(&wire.origin, MAX_ORIGIN_BYTES)
1169        || wire
1170            .rights
1171            .attribution
1172            .0
1173            .as_deref()
1174            .is_some_and(|value| !valid_bounded_text(value, MAX_RIGHTS_TEXT_BYTES))
1175        || wire
1176            .rights
1177            .notice
1178            .0
1179            .as_deref()
1180            .is_some_and(|value| !valid_bounded_text(value, MAX_RIGHTS_TEXT_BYTES))
1181    {
1182        return Err(ReconnaissanceSnapshotError::InvalidSnapshot);
1183    }
1184
1185    Ok(ReconnaissanceSource {
1186        source_id: tight_string(wire.source_id),
1187        namespace: tight_string(wire.namespace),
1188        revision: tight_string(wire.revision),
1189        provider_time: wire.provider_time.0.map(tight_string),
1190        observed_time: tight_string(wire.observed_time),
1191        collection_method: tight_string(wire.collection_method),
1192        completeness: wire.completeness.into(),
1193        origin: tight_string(wire.origin),
1194        rights: ReconnaissanceDeclaredRights {
1195            status: wire.rights.status.into(),
1196            attribution: wire.rights.attribution.0.map(tight_string),
1197            notice: wire.rights.notice.0.map(tight_string),
1198        },
1199    })
1200}
1201
1202fn valid_identifier(value: &str) -> bool {
1203    !value.is_empty()
1204        && value.len() <= MAX_IDENTIFIER_BYTES
1205        && value.bytes().all(|byte| {
1206            byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'/' | b'@' | b'-')
1207        })
1208}
1209
1210fn valid_ascii_text(value: &str, maximum: usize) -> bool {
1211    valid_bounded_text(value, maximum) && value.is_ascii()
1212}
1213
1214fn valid_bounded_text(value: &str, maximum: usize) -> bool {
1215    !value.is_empty()
1216        && value.len() <= maximum
1217        && !value.starts_with(char::is_whitespace)
1218        && !value.ends_with(char::is_whitespace)
1219        && !value.chars().any(char::is_control)
1220}
1221
1222fn valid_host(value: &str) -> bool {
1223    value.parse::<IpAddr>().is_ok() || valid_dns_name(value, false)
1224}
1225
1226fn valid_dns_name(value: &str, allow_wildcard: bool) -> bool {
1227    if value.is_empty() || value.len() > MAX_DNS_NAME_BYTES || !value.is_ascii() {
1228        return false;
1229    }
1230    let value = if allow_wildcard {
1231        value.strip_prefix("*.").unwrap_or(value)
1232    } else {
1233        value
1234    };
1235    if value.is_empty() || value.starts_with('.') || value.ends_with('.') {
1236        return false;
1237    }
1238    value.split('.').all(|label| {
1239        !label.is_empty()
1240            && label.len() <= 63
1241            && label.as_bytes()[0].is_ascii_alphanumeric()
1242            && label.as_bytes()[label.len() - 1].is_ascii_alphanumeric()
1243            && label
1244                .bytes()
1245                .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
1246    })
1247}
1248
1249fn tight_string(mut value: String) -> String {
1250    value.shrink_to_fit();
1251    value
1252}
1253
1254fn hex_digest(bytes: [u8; 32]) -> String {
1255    const HEX: &[u8; 16] = b"0123456789abcdef";
1256    let mut output = String::with_capacity(64);
1257    for byte in bytes {
1258        output.push(char::from(HEX[usize::from(byte >> 4)]));
1259        output.push(char::from(HEX[usize::from(byte & 0x0f)]));
1260    }
1261    output
1262}
1263
1264struct PreparedIndexBudget {
1265    used: usize,
1266    maximum: usize,
1267}
1268
1269impl PreparedIndexBudget {
1270    fn new(maximum: usize) -> Result<Self, ReconnaissanceSnapshotError> {
1271        let used = size_of::<PreparedIndex>();
1272        if used > maximum {
1273            return Err(ReconnaissanceSnapshotError::PreparedIndexTooLarge);
1274        }
1275        Ok(Self { used, maximum })
1276    }
1277
1278    fn add_map_entry(&mut self, key: &str) -> Result<(), ReconnaissanceSnapshotError> {
1279        self.add(size_of::<String>())?;
1280        self.add(size_of::<usize>())?;
1281        self.add(key.len())?;
1282        self.add(CONSERVATIVE_BTREE_ENTRY_OVERHEAD)
1283    }
1284
1285    fn add_resolved_sources(&mut self, capacity: usize) -> Result<(), ReconnaissanceSnapshotError> {
1286        let bytes = capacity
1287            .checked_mul(size_of::<usize>())
1288            .ok_or(ReconnaissanceSnapshotError::PreparedIndexTooLarge)?;
1289        self.add(bytes)
1290    }
1291
1292    fn add(&mut self, bytes: usize) -> Result<(), ReconnaissanceSnapshotError> {
1293        self.used = self
1294            .used
1295            .checked_add(bytes)
1296            .filter(|used| *used <= self.maximum)
1297            .ok_or(ReconnaissanceSnapshotError::PreparedIndexTooLarge)?;
1298        Ok(())
1299    }
1300
1301    const fn used(&self) -> usize {
1302        self.used
1303    }
1304}
1305
1306#[derive(Default)]
1307struct JsonBudget {
1308    nodes: usize,
1309    members: usize,
1310    array_items: usize,
1311}
1312
1313impl JsonBudget {
1314    fn enter<E: de::Error>(&mut self, depth: usize) -> Result<(), E> {
1315        if depth > MAX_JSON_DEPTH || self.nodes >= MAX_JSON_NODES {
1316            return Err(E::custom(JSON_LIMIT_MARKER));
1317        }
1318        self.nodes += 1;
1319        Ok(())
1320    }
1321
1322    fn member<E: de::Error>(&mut self) -> Result<(), E> {
1323        if self.members >= MAX_JSON_MEMBERS {
1324            return Err(E::custom(JSON_LIMIT_MARKER));
1325        }
1326        self.members += 1;
1327        Ok(())
1328    }
1329
1330    fn array_item<E: de::Error>(&mut self) -> Result<(), E> {
1331        if self.array_items >= MAX_JSON_ARRAY_ITEMS {
1332            return Err(E::custom(JSON_LIMIT_MARKER));
1333        }
1334        self.array_items += 1;
1335        Ok(())
1336    }
1337}
1338
1339struct StrictJsonSeed<'a> {
1340    budget: &'a mut JsonBudget,
1341    depth: usize,
1342}
1343
1344impl<'de> DeserializeSeed<'de> for StrictJsonSeed<'_> {
1345    type Value = Value;
1346
1347    fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
1348    where
1349        D: Deserializer<'de>,
1350    {
1351        self.budget.enter::<D::Error>(self.depth)?;
1352        deserializer.deserialize_any(StrictJsonVisitor {
1353            budget: self.budget,
1354            depth: self.depth,
1355        })
1356    }
1357}
1358
1359struct StrictJsonVisitor<'a> {
1360    budget: &'a mut JsonBudget,
1361    depth: usize,
1362}
1363
1364impl<'de> Visitor<'de> for StrictJsonVisitor<'_> {
1365    type Value = Value;
1366
1367    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1368        formatter.write_str("bounded reconnaissance snapshot JSON")
1369    }
1370
1371    fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
1372        Ok(Value::Null)
1373    }
1374
1375    fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
1376        Ok(Value::Null)
1377    }
1378
1379    fn visit_bool<E: de::Error>(self, value: bool) -> Result<Self::Value, E> {
1380        Ok(Value::Bool(value))
1381    }
1382
1383    fn visit_i64<E: de::Error>(self, value: i64) -> Result<Self::Value, E> {
1384        Ok(Value::Number(Number::from(value)))
1385    }
1386
1387    fn visit_u64<E: de::Error>(self, value: u64) -> Result<Self::Value, E> {
1388        Ok(Value::Number(Number::from(value)))
1389    }
1390
1391    fn visit_f64<E: de::Error>(self, value: f64) -> Result<Self::Value, E> {
1392        Number::from_f64(value)
1393            .map(Value::Number)
1394            .ok_or_else(|| E::custom("non-finite JSON number"))
1395    }
1396
1397    fn visit_borrowed_str<E: de::Error>(self, value: &'de str) -> Result<Self::Value, E> {
1398        self.visit_str(value)
1399    }
1400
1401    fn visit_str<E: de::Error>(self, value: &str) -> Result<Self::Value, E> {
1402        if value.len() > MAX_JSON_STRING_BYTES {
1403            return Err(E::custom(JSON_LIMIT_MARKER));
1404        }
1405        Ok(Value::String(value.to_owned()))
1406    }
1407
1408    fn visit_string<E: de::Error>(self, value: String) -> Result<Self::Value, E> {
1409        if value.len() > MAX_JSON_STRING_BYTES {
1410            return Err(E::custom(JSON_LIMIT_MARKER));
1411        }
1412        Ok(Value::String(value))
1413    }
1414
1415    fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
1416    where
1417        A: SeqAccess<'de>,
1418    {
1419        let mut values = Vec::new();
1420        while let Some(value) = sequence.next_element_seed(StrictJsonSeed {
1421            budget: &mut *self.budget,
1422            depth: self.depth + 1,
1423        })? {
1424            self.budget.array_item::<A::Error>()?;
1425            values.push(value);
1426        }
1427        Ok(Value::Array(values))
1428    }
1429
1430    fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
1431    where
1432        A: MapAccess<'de>,
1433    {
1434        let mut values = serde_json::Map::new();
1435        while let Some(key) = map.next_key_seed(StrictKeySeed)? {
1436            if values.contains_key(&key) {
1437                return Err(de::Error::custom(DUPLICATE_KEY_MARKER));
1438            }
1439            self.budget.member::<A::Error>()?;
1440            let value = map.next_value_seed(StrictJsonSeed {
1441                budget: &mut *self.budget,
1442                depth: self.depth + 1,
1443            })?;
1444            values.insert(key, value);
1445        }
1446        Ok(Value::Object(values))
1447    }
1448}
1449
1450struct StrictKeySeed;
1451
1452impl<'de> DeserializeSeed<'de> for StrictKeySeed {
1453    type Value = String;
1454
1455    fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
1456    where
1457        D: Deserializer<'de>,
1458    {
1459        deserializer.deserialize_str(StrictKeyVisitor)
1460    }
1461}
1462
1463struct StrictKeyVisitor;
1464
1465impl Visitor<'_> for StrictKeyVisitor {
1466    type Value = String;
1467
1468    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1469        formatter.write_str("a bounded reconnaissance snapshot object key")
1470    }
1471
1472    fn visit_borrowed_str<E: de::Error>(self, value: &str) -> Result<Self::Value, E> {
1473        self.visit_str(value)
1474    }
1475
1476    fn visit_str<E: de::Error>(self, value: &str) -> Result<Self::Value, E> {
1477        if value.len() > MAX_JSON_KEY_BYTES {
1478            return Err(E::custom(JSON_LIMIT_MARKER));
1479        }
1480        Ok(value.to_owned())
1481    }
1482
1483    fn visit_string<E: de::Error>(self, value: String) -> Result<Self::Value, E> {
1484        if value.len() > MAX_JSON_KEY_BYTES {
1485            return Err(E::custom(JSON_LIMIT_MARKER));
1486        }
1487        Ok(value)
1488    }
1489}
1490
1491fn parse_strict_json(bytes: &[u8]) -> Result<Value, ReconnaissanceSnapshotError> {
1492    let mut budget = JsonBudget::default();
1493    let mut deserializer = serde_json::Deserializer::from_slice(bytes);
1494    let value = StrictJsonSeed {
1495        budget: &mut budget,
1496        depth: 0,
1497    }
1498    .deserialize(&mut deserializer)
1499    .map_err(classify_json_error)?;
1500    deserializer.end().map_err(classify_json_error)?;
1501    Ok(value)
1502}
1503
1504fn classify_json_error(error: serde_json::Error) -> ReconnaissanceSnapshotError {
1505    let message = error.to_string();
1506    if message.contains(DUPLICATE_KEY_MARKER) {
1507        ReconnaissanceSnapshotError::DuplicateJsonKey
1508    } else if message.contains(JSON_LIMIT_MARKER) {
1509        ReconnaissanceSnapshotError::StructuralLimitExceeded
1510    } else {
1511        ReconnaissanceSnapshotError::MalformedJson
1512    }
1513}
1514
1515#[cfg(test)]
1516mod tests {
1517    use serde_json::{json, Value};
1518
1519    use super::*;
1520
1521    fn source(id: &str) -> Value {
1522        json!({
1523            "source_id": id,
1524            "namespace": "provider.example",
1525            "revision": "feed-2026-09",
1526            "provider_time": null,
1527            "observed_time": "2026-09-20T10:30:00Z",
1528            "collection_method": "offline-export",
1529            "completeness": "partial_as_declared",
1530            "origin": "https://provider.example/export",
1531            "rights": {
1532                "status": "permitted_as_declared",
1533                "attribution": "Synthetic fixture provider",
1534                "notice": null
1535            }
1536        })
1537    }
1538
1539    fn document() -> Value {
1540        json!({
1541            "schema": RECONNAISSANCE_SNAPSHOT_SCHEMA,
1542            "snapshot_id": "snapshot-fixture",
1543            "snapshot_revision": "revision-1",
1544            "sources": [source("source-a"), source("source-b")],
1545            "records": [
1546                {
1547                    "kind": "ct_name",
1548                    "record_id": "record-ct",
1549                    "source_ids": ["source-a"],
1550                    "name": "*.example.test"
1551                },
1552                {
1553                    "kind": "dns_history",
1554                    "record_id": "record-dns",
1555                    "source_ids": ["source-a", "source-b"],
1556                    "name": "api.example.test",
1557                    "address": "192.0.2.10"
1558                },
1559                {
1560                    "kind": "service_banner_product",
1561                    "record_id": "record-service",
1562                    "source_ids": ["source-b"],
1563                    "host": "192.0.2.10",
1564                    "port": 443,
1565                    "transport": "tcp",
1566                    "product": "fixture-server",
1567                    "version": null
1568                },
1569                {
1570                    "kind": "reputation_label",
1571                    "record_id": "record-reputation",
1572                    "source_ids": ["source-a"],
1573                    "subject": "api.example.test",
1574                    "label": "source-listed"
1575                }
1576            ]
1577        })
1578    }
1579
1580    fn encode(value: &Value) -> Vec<u8> {
1581        serde_json::to_vec(value).unwrap()
1582    }
1583
1584    #[test]
1585    fn parses_all_closed_hypotheses_and_exposes_audit_without_authority() {
1586        let bytes = encode(&document());
1587        let snapshot = parse_reconnaissance_snapshot(&bytes).unwrap();
1588
1589        assert_eq!(snapshot.schema(), RECONNAISSANCE_SNAPSHOT_SCHEMA);
1590        assert_eq!(snapshot.policy(), RECONNAISSANCE_SNAPSHOT_POLICY);
1591        assert_eq!(snapshot.snapshot_id(), "snapshot-fixture");
1592        assert_eq!(snapshot.snapshot_revision(), "revision-1");
1593        assert_eq!(snapshot.input().byte_length(), bytes.len() as u64);
1594        assert_eq!(
1595            snapshot.input().sha256(),
1596            &<[u8; 32]>::from(Sha256::digest(&bytes))
1597        );
1598        assert_eq!(snapshot.source_count(), 2);
1599        assert_eq!(snapshot.record_count(), 4);
1600        assert_eq!(snapshot.source_association_count(), 5);
1601        assert!(
1602            snapshot.prepared_index_bytes() <= MAX_RECONNAISSANCE_SNAPSHOT_PREPARED_INDEX_BYTES
1603        );
1604        assert_eq!(
1605            snapshot.claim_limit().as_str(),
1606            "source_qualified_hypotheses_only"
1607        );
1608        let activity = snapshot.external_activity();
1609        assert_eq!(activity.target_request_count(), 0);
1610        assert_eq!(activity.provider_request_count(), 0);
1611        assert_eq!(activity.archive_processing().as_str(), "not_performed");
1612        assert_eq!(activity.decompression().as_str(), "not_performed");
1613
1614        let source = snapshot.source("source-a").unwrap();
1615        assert_eq!(source.namespace(), "provider.example");
1616        assert_eq!(source.provider_time(), None);
1617        assert_eq!(
1618            source.completeness(),
1619            ReconnaissanceCompleteness::PartialAsDeclared
1620        );
1621        assert_eq!(
1622            source.rights().status(),
1623            ReconnaissanceDeclaredRightsStatus::PermittedAsDeclared
1624        );
1625        assert_eq!(
1626            source.rights().attribution(),
1627            Some("Synthetic fixture provider")
1628        );
1629
1630        assert!(matches!(
1631            snapshot.record("record-ct").unwrap().value(),
1632            ReconnaissanceRecordValue::CtName(value) if value.name() == "*.example.test"
1633        ));
1634        assert!(matches!(
1635            snapshot.record("record-dns").unwrap().value(),
1636            ReconnaissanceRecordValue::DnsHistory(value)
1637                if value.address() == "192.0.2.10".parse::<IpAddr>().unwrap()
1638        ));
1639        assert!(matches!(
1640            snapshot.record("record-service").unwrap().value(),
1641            ReconnaissanceRecordValue::ServiceBannerProduct(value)
1642                if value.port() == 443
1643                    && value.transport() == ReconnaissanceTransport::Tcp
1644                    && value.version().is_none()
1645        ));
1646        assert!(matches!(
1647            snapshot.record("record-reputation").unwrap().value(),
1648            ReconnaissanceRecordValue::ReputationLabel(value)
1649                if value.label() == "source-listed"
1650        ));
1651    }
1652
1653    #[test]
1654    fn exact_input_identity_is_separate_from_declared_snapshot_provenance() {
1655        let compact = encode(&document());
1656        let pretty = serde_json::to_vec_pretty(&document()).unwrap();
1657        let compact = parse_reconnaissance_snapshot(&compact).unwrap();
1658        let pretty = parse_reconnaissance_snapshot(&pretty).unwrap();
1659
1660        assert_eq!(compact.snapshot_id(), pretty.snapshot_id());
1661        assert_eq!(compact.snapshot_revision(), pretty.snapshot_revision());
1662        assert_ne!(compact.input().byte_length(), pretty.input().byte_length());
1663        assert_ne!(compact.input().sha256(), pretty.input().sha256());
1664    }
1665
1666    #[test]
1667    fn duplicate_json_keys_are_rejected_at_every_depth_after_decoding() {
1668        let record =
1669            r#"{"kind":"ct_name","record_id":"r","source_ids":["s"],"name":"example.test"}"#;
1670        let source = r#"{"source_id":"s","namespace":"n","revision":"r","provider_time":null,"observed_time":"t","collection_method":"m","completeness":"unknown","origin":"o","rights":{"status":"unknown","attribution":null,"notice":null}}"#;
1671        let root = format!(
1672            r#"{{"schema":"{RECONNAISSANCE_SNAPSHOT_SCHEMA}","schema":"{RECONNAISSANCE_SNAPSHOT_SCHEMA}","snapshot_id":"s","snapshot_revision":"r","sources":[{source}],"records":[{record}]}}"#
1673        );
1674        assert_eq!(
1675            parse_reconnaissance_snapshot(root.as_bytes()),
1676            Err(ReconnaissanceSnapshotError::DuplicateJsonKey)
1677        );
1678
1679        let nested = format!(
1680            r#"{{"schema":"{RECONNAISSANCE_SNAPSHOT_SCHEMA}","snapshot_id":"s","snapshot_revision":"r","sources":[{{"source_id":"s","namespace":"n","revision":"r","provider_time":null,"observed_time":"t","collection_method":"m","completeness":"unknown","origin":"o","rights":{{"status":"unknown","attribution":null,"not\u0069ce":null,"notice":"duplicate"}}}}],"records":[{record}]}}"#
1681        );
1682        assert_eq!(
1683            parse_reconnaissance_snapshot(nested.as_bytes()),
1684            Err(ReconnaissanceSnapshotError::DuplicateJsonKey)
1685        );
1686    }
1687
1688    #[test]
1689    fn duplicate_and_unresolved_identities_fail_closed() {
1690        let mut duplicate_source = document();
1691        duplicate_source["sources"][1]["source_id"] = json!("source-a");
1692        assert_eq!(
1693            parse_reconnaissance_snapshot(&encode(&duplicate_source)),
1694            Err(ReconnaissanceSnapshotError::DuplicateSourceId)
1695        );
1696
1697        let mut duplicate_record = document();
1698        duplicate_record["records"][1]["record_id"] = json!("record-ct");
1699        assert_eq!(
1700            parse_reconnaissance_snapshot(&encode(&duplicate_record)),
1701            Err(ReconnaissanceSnapshotError::DuplicateRecordId)
1702        );
1703
1704        let mut duplicate_link = document();
1705        duplicate_link["records"][0]["source_ids"] = json!(["source-a", "source-a"]);
1706        assert_eq!(
1707            parse_reconnaissance_snapshot(&encode(&duplicate_link)),
1708            Err(ReconnaissanceSnapshotError::DuplicateRecordSource)
1709        );
1710
1711        let mut unknown = document();
1712        unknown["records"][0]["source_ids"] = json!(["source-missing"]);
1713        assert_eq!(
1714            parse_reconnaissance_snapshot(&encode(&unknown)),
1715            Err(ReconnaissanceSnapshotError::UnknownRecordSource)
1716        );
1717    }
1718
1719    #[test]
1720    fn nullable_fields_are_required_and_types_are_strict() {
1721        for pointer in [
1722            "/sources/0/provider_time",
1723            "/sources/0/rights/attribution",
1724            "/sources/0/rights/notice",
1725            "/records/2/version",
1726        ] {
1727            let mut value = document();
1728            let (parent, field) = pointer.rsplit_once('/').unwrap();
1729            value
1730                .pointer_mut(parent)
1731                .unwrap()
1732                .as_object_mut()
1733                .unwrap()
1734                .remove(field);
1735            assert_eq!(
1736                parse_reconnaissance_snapshot(&encode(&value)),
1737                Err(ReconnaissanceSnapshotError::InvalidSnapshot),
1738                "{pointer}"
1739            );
1740        }
1741
1742        let mut boolean_port = document();
1743        boolean_port["records"][2]["port"] = json!(true);
1744        assert_eq!(
1745            parse_reconnaissance_snapshot(&encode(&boolean_port)),
1746            Err(ReconnaissanceSnapshotError::InvalidSnapshot)
1747        );
1748
1749        let mut integer_completeness = document();
1750        integer_completeness["sources"][0]["completeness"] = json!(1);
1751        assert_eq!(
1752            parse_reconnaissance_snapshot(&encode(&integer_completeness)),
1753            Err(ReconnaissanceSnapshotError::InvalidSnapshot)
1754        );
1755    }
1756
1757    #[test]
1758    fn unknown_fields_and_open_ended_enum_values_are_rejected() {
1759        let mut unknown_root = document();
1760        unknown_root["target"] = json!("https://example.test");
1761        assert_eq!(
1762            parse_reconnaissance_snapshot(&encode(&unknown_root)),
1763            Err(ReconnaissanceSnapshotError::InvalidSnapshot)
1764        );
1765
1766        let mut unknown_record = document();
1767        unknown_record["records"][0]["confidence"] = json!(true);
1768        assert_eq!(
1769            parse_reconnaissance_snapshot(&encode(&unknown_record)),
1770            Err(ReconnaissanceSnapshotError::InvalidSnapshot)
1771        );
1772
1773        let mut kind = document();
1774        kind["records"][0]["kind"] = json!("active_probe");
1775        assert_eq!(
1776            parse_reconnaissance_snapshot(&encode(&kind)),
1777            Err(ReconnaissanceSnapshotError::InvalidSnapshot)
1778        );
1779
1780        let mut rights = document();
1781        rights["sources"][0]["rights"]["status"] = json!("verified_permitted");
1782        assert_eq!(
1783            parse_reconnaissance_snapshot(&encode(&rights)),
1784            Err(ReconnaissanceSnapshotError::InvalidSnapshot)
1785        );
1786    }
1787
1788    #[test]
1789    fn host_name_address_and_text_boundaries_are_enforced() {
1790        for invalid_name in [
1791            "https://example.test",
1792            "user@example.test",
1793            "bad name.example",
1794            "-bad.example",
1795            "bad-.example",
1796            "*.example.test/path",
1797            "éxample.test",
1798        ] {
1799            let mut value = document();
1800            value["records"][0]["name"] = json!(invalid_name);
1801            assert_eq!(
1802                parse_reconnaissance_snapshot(&encode(&value)),
1803                Err(ReconnaissanceSnapshotError::InvalidSnapshot),
1804                "{invalid_name}"
1805            );
1806        }
1807
1808        let mut wildcard_dns = document();
1809        wildcard_dns["records"][1]["name"] = json!("*.example.test");
1810        assert_eq!(
1811            parse_reconnaissance_snapshot(&encode(&wildcard_dns)),
1812            Err(ReconnaissanceSnapshotError::InvalidSnapshot)
1813        );
1814
1815        let mut invalid_address = document();
1816        invalid_address["records"][1]["address"] = json!("999.0.2.1");
1817        assert_eq!(
1818            parse_reconnaissance_snapshot(&encode(&invalid_address)),
1819            Err(ReconnaissanceSnapshotError::InvalidSnapshot)
1820        );
1821
1822        let mut zero_port = document();
1823        zero_port["records"][2]["port"] = json!(0);
1824        assert_eq!(
1825            parse_reconnaissance_snapshot(&encode(&zero_port)),
1826            Err(ReconnaissanceSnapshotError::InvalidSnapshot)
1827        );
1828
1829        let mut control = document();
1830        control["records"][3]["label"] = json!("bad\nlabel");
1831        assert_eq!(
1832            parse_reconnaissance_snapshot(&encode(&control)),
1833            Err(ReconnaissanceSnapshotError::InvalidSnapshot)
1834        );
1835    }
1836
1837    #[test]
1838    fn byte_structure_record_association_and_index_limits_fail_closed() {
1839        assert_eq!(
1840            parse_reconnaissance_snapshot(&[]),
1841            Err(ReconnaissanceSnapshotError::EmptyInput)
1842        );
1843        assert_eq!(
1844            parse_reconnaissance_snapshot(&vec![b' '; MAX_RECONNAISSANCE_SNAPSHOT_INPUT_BYTES + 1]),
1845            Err(ReconnaissanceSnapshotError::InputTooLarge)
1846        );
1847
1848        let mut no_sources = document();
1849        no_sources["sources"] = json!([]);
1850        no_sources["records"] = json!([]);
1851        assert_eq!(
1852            parse_reconnaissance_snapshot(&encode(&no_sources)),
1853            Err(ReconnaissanceSnapshotError::InvalidSnapshot)
1854        );
1855
1856        let mut too_many_sources = document();
1857        too_many_sources["sources"] = Value::Array(
1858            (0..=MAX_RECONNAISSANCE_SNAPSHOT_SOURCES)
1859                .map(|index| source(&format!("source-{index}")))
1860                .collect(),
1861        );
1862        assert_eq!(
1863            parse_reconnaissance_snapshot(&encode(&too_many_sources)),
1864            Err(ReconnaissanceSnapshotError::TooManySources)
1865        );
1866
1867        let nested = format!(
1868            "{}0{}",
1869            "[".repeat(MAX_JSON_DEPTH + 1),
1870            "]".repeat(MAX_JSON_DEPTH + 1)
1871        );
1872        assert_eq!(
1873            parse_reconnaissance_snapshot(nested.as_bytes()),
1874            Err(ReconnaissanceSnapshotError::StructuralLimitExceeded)
1875        );
1876
1877        let mut too_many_records = document();
1878        let template = too_many_records["records"][0].clone();
1879        too_many_records["records"] = Value::Array(
1880            (0..=MAX_RECONNAISSANCE_SNAPSHOT_RECORDS)
1881                .map(|index| {
1882                    let mut record = template.clone();
1883                    record["record_id"] = json!(format!("record-{index}"));
1884                    record
1885                })
1886                .collect(),
1887        );
1888        assert_eq!(
1889            parse_reconnaissance_snapshot(&encode(&too_many_records)),
1890            Err(ReconnaissanceSnapshotError::TooManyRecords)
1891        );
1892
1893        let mut too_many_associations = document();
1894        too_many_associations["records"] = Value::Array(
1895            (0..MAX_RECONNAISSANCE_SNAPSHOT_RECORDS)
1896                .map(|index| {
1897                    json!({
1898                        "kind": "ct_name",
1899                        "record_id": format!("record-{index}"),
1900                        "source_ids": ["source-a", "source-b", "source-a2", "source-b2", "source-c"],
1901                        "name": "example.test"
1902                    })
1903                })
1904                .collect(),
1905        );
1906        too_many_associations["sources"] = json!([
1907            source("source-a"),
1908            source("source-b"),
1909            source("source-a2"),
1910            source("source-b2"),
1911            source("source-c")
1912        ]);
1913        assert_eq!(
1914            parse_reconnaissance_snapshot(&encode(&too_many_associations)),
1915            Err(ReconnaissanceSnapshotError::TooManySourceAssociations)
1916        );
1917
1918        let bytes = encode(&document());
1919        assert_eq!(
1920            parse_with_limits(&bytes, MAX_RECONNAISSANCE_SNAPSHOT_INPUT_BYTES, 1),
1921            Err(ReconnaissanceSnapshotError::PreparedIndexTooLarge)
1922        );
1923    }
1924
1925    #[test]
1926    fn compressed_or_archive_inputs_are_never_interpreted() {
1927        for bytes in [
1928            b"PK\x03\x04not-a-json-snapshot".as_slice(),
1929            b"\x1f\x8b\x08not-a-json-snapshot".as_slice(),
1930        ] {
1931            assert_eq!(
1932                parse_reconnaissance_snapshot(bytes),
1933                Err(ReconnaissanceSnapshotError::MalformedJson)
1934            );
1935        }
1936    }
1937}