1use 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
24pub const RECONNAISSANCE_SNAPSHOT_SCHEMA: &str = "security.recon-snapshot/v1";
26pub const RECONNAISSANCE_SNAPSHOT_POLICY: &str = "termivar.recon-snapshot-import/v1";
28pub const MAX_RECONNAISSANCE_SNAPSHOT_INPUT_BYTES: usize = 1024 * 1024;
30pub const MAX_RECON_SNAPSHOT_BYTES: usize = MAX_RECONNAISSANCE_SNAPSHOT_INPUT_BYTES;
32pub const MAX_RECONNAISSANCE_SNAPSHOT_SOURCES: usize = 1024;
34pub const MAX_RECONNAISSANCE_SNAPSHOT_RECORDS: usize = 1024;
36pub const MAX_RECONNAISSANCE_SNAPSHOT_SOURCE_ASSOCIATIONS: usize = 4096;
38pub 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#[derive(Clone, Debug, Eq, PartialEq)]
65pub struct ReconnaissanceSnapshotInputIdentity {
66 byte_length: u64,
67 sha256: [u8; 32],
68}
69
70impl ReconnaissanceSnapshotInputIdentity {
71 #[must_use]
73 pub const fn byte_length(&self) -> u64 {
74 self.byte_length
75 }
76
77 #[must_use]
79 pub const fn sha256(&self) -> &[u8; 32] {
80 &self.sha256
81 }
82
83 #[must_use]
85 pub fn sha256_hex(&self) -> String {
86 hex_digest(self.sha256)
87 }
88}
89
90#[derive(Clone, Copy, Debug, Eq, PartialEq)]
92pub enum ReconnaissanceClaimLimit {
93 SourceQualifiedHypothesesOnly,
96}
97
98impl ReconnaissanceClaimLimit {
99 #[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
110pub enum ReconnaissanceExternalOperationStatus {
111 NotPerformed,
113}
114
115impl ReconnaissanceExternalOperationStatus {
116 #[must_use]
118 pub const fn as_str(self) -> &'static str {
119 match self {
120 Self::NotPerformed => "not_performed",
121 }
122 }
123}
124
125#[derive(Clone, Copy, Debug, Eq, PartialEq)]
127pub struct ReconnaissanceExternalActivity;
128
129impl ReconnaissanceExternalActivity {
130 #[must_use]
132 pub const fn target_request_count(self) -> u8 {
133 0
134 }
135
136 #[must_use]
138 pub const fn provider_request_count(self) -> u8 {
139 0
140 }
141
142 #[must_use]
144 pub const fn archive_processing(self) -> ReconnaissanceExternalOperationStatus {
145 ReconnaissanceExternalOperationStatus::NotPerformed
146 }
147
148 #[must_use]
150 pub const fn decompression(self) -> ReconnaissanceExternalOperationStatus {
151 ReconnaissanceExternalOperationStatus::NotPerformed
152 }
153}
154
155#[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 #[must_use]
168 pub const fn schema(&self) -> &'static str {
169 RECONNAISSANCE_SNAPSHOT_SCHEMA
170 }
171
172 #[must_use]
174 pub const fn policy(&self) -> &'static str {
175 RECONNAISSANCE_SNAPSHOT_POLICY
176 }
177
178 #[must_use]
180 pub const fn input(&self) -> &ReconnaissanceSnapshotInputIdentity {
181 &self.input
182 }
183
184 #[must_use]
186 pub const fn input_byte_length(&self) -> u64 {
187 self.input.byte_length
188 }
189
190 #[must_use]
192 pub const fn input_sha256(&self) -> &[u8; 32] {
193 &self.input.sha256
194 }
195
196 #[must_use]
198 pub const fn source_count(&self) -> usize {
199 self.source_count
200 }
201
202 #[must_use]
204 pub const fn record_count(&self) -> usize {
205 self.record_count
206 }
207
208 #[must_use]
210 pub const fn source_association_count(&self) -> usize {
211 self.source_association_count
212 }
213
214 #[must_use]
216 pub const fn prepared_index_bytes(&self) -> usize {
217 self.prepared_index_bytes
218 }
219
220 #[must_use]
222 pub const fn external_activity(&self) -> ReconnaissanceExternalActivity {
223 ReconnaissanceExternalActivity
224 }
225
226 #[must_use]
228 pub const fn claim_limit(&self) -> ReconnaissanceClaimLimit {
229 ReconnaissanceClaimLimit::SourceQualifiedHypothesesOnly
230 }
231}
232
233#[derive(Clone, Copy, Debug, Eq, PartialEq)]
237pub enum ReconnaissanceCompleteness {
238 CompleteAsDeclared,
241 PartialAsDeclared,
243 Unknown,
245}
246
247impl ReconnaissanceCompleteness {
248 #[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
264pub enum ReconnaissanceDeclaredRightsStatus {
265 PermittedAsDeclared,
267 RestrictedAsDeclared,
269 Unknown,
271}
272
273impl ReconnaissanceDeclaredRightsStatus {
274 #[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#[derive(Clone, Debug, Eq, PartialEq)]
287pub struct ReconnaissanceDeclaredRights {
288 status: ReconnaissanceDeclaredRightsStatus,
289 attribution: Option<String>,
290 notice: Option<String>,
291}
292
293impl ReconnaissanceDeclaredRights {
294 #[must_use]
296 pub const fn status(&self) -> ReconnaissanceDeclaredRightsStatus {
297 self.status
298 }
299
300 #[must_use]
302 pub fn attribution(&self) -> Option<&str> {
303 self.attribution.as_deref()
304 }
305
306 #[must_use]
308 pub fn notice(&self) -> Option<&str> {
309 self.notice.as_deref()
310 }
311}
312
313#[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 #[must_use]
330 pub fn source_id(&self) -> &str {
331 &self.source_id
332 }
333
334 #[must_use]
336 pub fn namespace(&self) -> &str {
337 &self.namespace
338 }
339
340 #[must_use]
342 pub fn revision(&self) -> &str {
343 &self.revision
344 }
345
346 #[must_use]
348 pub fn provider_time(&self) -> Option<&str> {
349 self.provider_time.as_deref()
350 }
351
352 #[must_use]
354 pub fn observed_time(&self) -> &str {
355 &self.observed_time
356 }
357
358 #[must_use]
360 pub fn collection_method(&self) -> &str {
361 &self.collection_method
362 }
363
364 #[must_use]
366 pub const fn completeness(&self) -> ReconnaissanceCompleteness {
367 self.completeness
368 }
369
370 #[must_use]
372 pub fn origin(&self) -> &str {
373 &self.origin
374 }
375
376 #[must_use]
378 pub const fn rights(&self) -> &ReconnaissanceDeclaredRights {
379 &self.rights
380 }
381}
382
383#[derive(Clone, Copy, Debug, Eq, PartialEq)]
385pub enum ReconnaissanceTransport {
386 Tcp,
388 Udp,
390}
391
392impl ReconnaissanceTransport {
393 #[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
405pub enum ReconnaissanceRecordKind {
406 CtName,
408 DnsHistory,
410 ServiceBannerProduct,
412 ReputationLabel,
414}
415
416impl ReconnaissanceRecordKind {
417 #[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#[derive(Clone, Debug, Eq, PartialEq)]
431pub struct CtNameHypothesis {
432 name: String,
433}
434
435impl CtNameHypothesis {
436 #[must_use]
438 pub fn name(&self) -> &str {
439 &self.name
440 }
441}
442
443#[derive(Clone, Debug, Eq, PartialEq)]
445pub struct DnsHistoryHypothesis {
446 name: String,
447 address: IpAddr,
448}
449
450impl DnsHistoryHypothesis {
451 #[must_use]
453 pub fn name(&self) -> &str {
454 &self.name
455 }
456
457 #[must_use]
459 pub const fn address(&self) -> IpAddr {
460 self.address
461 }
462}
463
464#[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 #[must_use]
477 pub fn host(&self) -> &str {
478 &self.host
479 }
480
481 #[must_use]
483 pub const fn port(&self) -> u16 {
484 self.port
485 }
486
487 #[must_use]
489 pub const fn transport(&self) -> ReconnaissanceTransport {
490 self.transport
491 }
492
493 #[must_use]
495 pub fn product(&self) -> &str {
496 &self.product
497 }
498
499 #[must_use]
501 pub fn version(&self) -> Option<&str> {
502 self.version.as_deref()
503 }
504}
505
506#[derive(Clone, Debug, Eq, PartialEq)]
508pub struct ReputationLabelHypothesis {
509 subject: String,
510 label: String,
511}
512
513impl ReputationLabelHypothesis {
514 #[must_use]
516 pub fn subject(&self) -> &str {
517 &self.subject
518 }
519
520 #[must_use]
522 pub fn label(&self) -> &str {
523 &self.label
524 }
525}
526
527#[derive(Clone, Debug, Eq, PartialEq)]
529pub enum ReconnaissanceRecordValue {
530 CtName(CtNameHypothesis),
532 DnsHistory(DnsHistoryHypothesis),
534 ServiceBannerProduct(ServiceBannerProductHypothesis),
536 ReputationLabel(ReputationLabelHypothesis),
538}
539
540impl ReconnaissanceRecordValue {
541 #[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#[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
565pub type ReconnaissanceHypothesis = ReconnaissanceRecord;
567
568impl ReconnaissanceRecord {
569 #[must_use]
571 pub fn record_id(&self) -> &str {
572 &self.record_id
573 }
574
575 #[must_use]
577 pub fn source_ids(&self) -> &[String] {
578 &self.source_ids
579 }
580
581 #[must_use]
583 pub const fn kind(&self) -> ReconnaissanceRecordKind {
584 self.value.kind()
585 }
586
587 #[must_use]
589 pub const fn value(&self) -> &ReconnaissanceRecordValue {
590 &self.value
591 }
592
593 #[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#[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
617pub type ReconSnapshot = ReconnaissanceSnapshot;
619
620pub type ReconSnapshotError = ReconnaissanceSnapshotError;
622
623impl ReconnaissanceSnapshot {
624 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 #[must_use]
635 pub const fn schema(&self) -> &'static str {
636 RECONNAISSANCE_SNAPSHOT_SCHEMA
637 }
638
639 #[must_use]
641 pub const fn policy(&self) -> &'static str {
642 RECONNAISSANCE_SNAPSHOT_POLICY
643 }
644
645 #[must_use]
647 pub fn snapshot_id(&self) -> &str {
648 &self.snapshot_id
649 }
650
651 #[must_use]
653 pub fn snapshot_revision(&self) -> &str {
654 &self.snapshot_revision
655 }
656
657 #[must_use]
659 pub const fn audit(&self) -> &ReconnaissanceSnapshotAudit {
660 &self.audit
661 }
662
663 #[must_use]
665 pub const fn input(&self) -> &ReconnaissanceSnapshotInputIdentity {
666 &self.audit.input
667 }
668
669 #[must_use]
671 pub fn sources(&self) -> &[ReconnaissanceSource] {
672 &self.sources
673 }
674
675 #[must_use]
677 pub fn records(&self) -> &[ReconnaissanceRecord] {
678 &self.records
679 }
680
681 #[must_use]
683 pub fn hypotheses(&self) -> &[ReconnaissanceHypothesis] {
684 &self.records
685 }
686
687 #[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 #[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 #[must_use]
707 pub const fn source_count(&self) -> usize {
708 self.audit.source_count
709 }
710
711 #[must_use]
713 pub const fn record_count(&self) -> usize {
714 self.audit.record_count
715 }
716
717 #[must_use]
719 pub const fn source_association_count(&self) -> usize {
720 self.audit.source_association_count
721 }
722
723 #[must_use]
725 pub const fn prepared_index_bytes(&self) -> usize {
726 self.audit.prepared_index_bytes
727 }
728
729 #[must_use]
731 pub const fn external_activity(&self) -> ReconnaissanceExternalActivity {
732 ReconnaissanceExternalActivity
733 }
734
735 #[must_use]
737 pub const fn claim_limit(&self) -> ReconnaissanceClaimLimit {
738 ReconnaissanceClaimLimit::SourceQualifiedHypothesesOnly
739 }
740}
741
742pub fn parse_reconnaissance_snapshot(
744 bytes: &[u8],
745) -> Result<ReconnaissanceSnapshot, ReconnaissanceSnapshotError> {
746 ReconnaissanceSnapshot::parse_json(bytes)
747}
748
749pub fn parse_recon_snapshot(bytes: &[u8]) -> Result<ReconSnapshot, ReconSnapshotError> {
752 parse_reconnaissance_snapshot(bytes)
753}
754
755#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
757#[non_exhaustive]
758pub enum ReconnaissanceSnapshotError {
759 #[error("reconnaissance snapshot input is empty")]
761 EmptyInput,
762 #[error("reconnaissance snapshot input exceeds its byte limit")]
764 InputTooLarge,
765 #[error("reconnaissance snapshot input is malformed JSON")]
767 MalformedJson,
768 #[error("reconnaissance snapshot input contains a duplicate JSON key")]
770 DuplicateJsonKey,
771 #[error("reconnaissance snapshot input exceeds a structural limit")]
773 StructuralLimitExceeded,
774 #[error("reconnaissance snapshot schema is unsupported")]
776 UnsupportedSchema,
777 #[error("reconnaissance snapshot contains an invalid or unsupported value")]
779 InvalidSnapshot,
780 #[error("reconnaissance snapshot contains too many sources")]
782 TooManySources,
783 #[error("reconnaissance snapshot contains a duplicate source identifier")]
785 DuplicateSourceId,
786 #[error("reconnaissance snapshot contains too many records")]
788 TooManyRecords,
789 #[error("reconnaissance snapshot contains a duplicate record identifier")]
791 DuplicateRecordId,
792 #[error("reconnaissance snapshot record contains a duplicate source reference")]
794 DuplicateRecordSource,
795 #[error("reconnaissance snapshot record contains an unresolved source reference")]
797 UnknownRecordSource,
798 #[error("reconnaissance snapshot contains too many source associations")]
800 TooManySourceAssociations,
801 #[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}