autumn-cli 0.10.0

CLI tool for the Autumn web framework
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
//! Reading a security posture manifest back in, and reducing it to the
//! security-relevant projection everything else in this module compares.
//!
//! The emitting side (`crate::routes_audit`) owns `Serialize` types whose
//! `provenance` fields are `&'static str`; this is the deliberately separate
//! `Deserialize` side. Keeping the two apart buys forward tolerance: an unknown
//! field added by a later schema revision is ignored here rather than failing
//! the gate, while a *newer* `schema_version` than this CLI understands is
//! refused outright (a silently mis-read manifest is worse than no gate).

use std::collections::BTreeSet;
use std::fmt::Write as _;

use serde::Deserialize;
use sha2::{Digest, Sha256};

use crate::routes_audit::MANIFEST_SCHEMA_VERSION;

/// Oldest manifest schema this differ can read.
///
/// v3 is the first revision that carries all four dimensions; nothing older
/// ever shipped a `posture diff`, so there is no compatibility debt to pay.
pub const MIN_SCHEMA_VERSION: u32 = 3;

/// Newest manifest schema this differ can read: whatever this CLI itself emits.
///
/// A manifest from a newer CLI is refused rather than read tolerantly, because
/// tolerance is only safe for *added* fields. When
/// [`MANIFEST_SCHEMA_VERSION`](crate::routes_audit::MANIFEST_SCHEMA_VERSION) is
/// next bumped, re-read [`PostureManifest::projection`] below: if the bump changes the
/// meaning of an existing field rather than adding new ones, the diff rules move with
/// it.
///
/// v4 (#1640) *adds* the `mtls` dimension and changes no existing field, so v3
/// still reads correctly: a v3 manifest has no `mtls` key, defaults to an empty
/// dimension, and contributes no `mtls` projection lines. A route that gains an
/// mTLS requirement therefore shows up as an added line, never as a silent
/// re-interpretation of an old one.
///
/// v5 (#2472) *adds* the top-level `build`. A v3 or v4 manifest has none, and
/// reads as the dev build with default features, which is the only build
/// those versions could come from.
///
/// v6 (#2468) *adds* `provenance` to each route entry, and that is the one
/// addition that cannot be read tolerantly: a CLI without the field would
/// read a `declared` layer guard as a proven one and miss the downgrade. So
/// the bump exists to make an older CLI refuse the manifest. A v3 to v5
/// manifest has no per-entry `provenance`, and every guard in it was proven.
pub const MAX_SCHEMA_VERSION: u32 = 6;

// Deliberately a literal, not `MANIFEST_SCHEMA_VERSION`. Tracking the emitter
// would auto-widen what this differ accepts on the very bump whose doc comment
// says to re-read the rules — the compile error below is the point.
//
// Equality, not `<=`: the comment above promises a compile error when the
// *emitter* is bumped, and `<=` only caught the opposite mistake. A CLI that
// emitted v4 while refusing to parse anything above v3 would have compiled
// cleanly and broken every generated gate at run time instead.
const _: () = assert!(
    MAX_SCHEMA_VERSION == MANIFEST_SCHEMA_VERSION,
    "bump both: this differ reads exactly the schema this CLI emits, and a bump means re-reading `projection`"
);

/// Why a manifest could not be turned into a [`PostureManifest`].
#[derive(Debug)]
pub enum ManifestError {
    /// The file could not be read.
    Io {
        path: String,
        source: std::io::Error,
    },
    /// The bytes are not the JSON this expects.
    Malformed { path: String, message: String },
    /// The document announces a schema this CLI does not understand.
    UnsupportedSchema { path: String, found: u32 },
}

impl std::fmt::Display for ManifestError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Io { path, source } => write!(f, "cannot read {path}: {source}"),
            Self::Malformed { path, message } => {
                write!(f, "{path} is not a security posture manifest: {message}")
            }
            Self::UnsupportedSchema { path, found } => write!(
                f,
                "{path} declares manifest schema v{found}, but this CLI understands \
                 v{MIN_SCHEMA_VERSION}..=v{MAX_SCHEMA_VERSION} — upgrade `autumn` \
                 (or regenerate the manifest with the CLI this project pins)"
            ),
        }
    }
}

impl std::error::Error for ManifestError {}

/// A security posture manifest, as read back from disk.
#[derive(Debug, Clone, Deserialize)]
pub struct PostureManifest {
    pub schema_version: u32,
    /// The Cargo build the manifest describes (schema v5, #2472).
    #[serde(default)]
    pub build: BuildConfig,
    #[serde(default)]
    pub dimensions: Dimensions,
    /// Route rows a pre-v5 emitter wrote by mistake. The diff does not see
    /// them, but the digest does, so a digest a release recorded still
    /// matches.
    #[serde(skip)]
    pub legacy_rows: Vec<RouteEntry>,
}

/// The Cargo build a manifest describes. A missing field reads as the dev
/// build with default features.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct BuildConfig {
    pub profile: String,
    pub features: Vec<String>,
    pub all_features: bool,
    pub no_default_features: bool,
}

impl Default for BuildConfig {
    fn default() -> Self {
        Self {
            profile: "dev".to_owned(),
            features: Vec::new(),
            all_features: false,
            no_default_features: false,
        }
    }
}

impl BuildConfig {
    /// Whether this is a plain `cargo build`.
    #[must_use]
    pub fn is_default(&self) -> bool {
        *self == Self::default()
    }

    /// One escaped line that changes when the build changes.
    #[must_use]
    pub fn canonical(&self) -> String {
        format!(
            "profile={}\tfeatures={}\tall-features={}\tno-default-features={}",
            escape_field(&self.profile),
            escape_list(&self.features),
            self.all_features,
            self.no_default_features
        )
    }

    /// The build in words, e.g. `release profile; features: embed-assets`.
    #[must_use]
    pub fn label(&self) -> String {
        let mut parts = vec![format!("{} profile", self.profile)];
        if self.no_default_features {
            parts.push("no default features".to_owned());
        }
        if self.all_features {
            parts.push("all features".to_owned());
        }
        if !self.features.is_empty() {
            parts.push(format!("features: {}", self.features.join(", ")));
        }
        if parts.len() == 1 {
            parts.push("default features".to_owned());
        }
        parts.join("; ")
    }
}

/// The manifest dimensions. Every one defaults to empty so a manifest that
/// predates a dimension — or omits one — reads as "nothing declared here"
/// instead of failing the gate.
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Dimensions {
    #[serde(default)]
    pub routes: RoutesDimension,
    #[serde(default)]
    pub csrf: CsrfDimension,
    #[serde(default)]
    pub security_headers: HeadersDimension,
    #[serde(default)]
    pub authorization_policies: AuthorizationPoliciesDimension,
    /// mTLS client-certificate requirements (schema v4, #1640). Absent on a v3
    /// manifest, which reads as "no route requires mTLS".
    #[serde(default)]
    pub mtls: MtlsDimension,
}

#[derive(Debug, Clone, Default, Deserialize)]
pub struct RoutesDimension {
    #[serde(default)]
    pub entries: Vec<RouteEntry>,
}

/// One route's proven auth posture.
///
/// `name`, `location`, `module` and `source` are deliberately **absent**: a
/// handler rename or a moved line is not a posture change, and including them
/// would make every refactor a finding.
#[derive(Debug, Clone, Deserialize)]
pub struct RouteEntry {
    pub path: String,
    pub method: String,
    #[serde(default)]
    pub classification: String,
    #[serde(default)]
    pub roles: Vec<String>,
    #[serde(default)]
    pub scopes: Vec<String>,
    #[serde(default)]
    pub policy: bool,
    /// `declared` when a layer guard is the app's claim (`scoped_guarded`).
    /// Absent or `provable` otherwise.
    #[serde(default)]
    pub provenance: String,
}

#[derive(Debug, Clone, Default, Deserialize)]
pub struct CsrfDimension {
    /// `security.csrf.exempt_paths`, verbatim.
    ///
    /// Not derivable from `entries`: the audit asks whether a route *template*
    /// matches a prefix, while the runtime asks it of the concrete request
    /// path. So exempting `/users/me` leaves `POST /users/{id}` recorded as
    /// enforced, and the requests it stops validating are invisible in the
    /// per-route rows. Dropping the field on parse kept it out of the digest
    /// and the diff both.
    #[serde(default)]
    pub exempt_paths: Vec<String>,
    #[serde(default)]
    pub entries: Vec<CsrfEntry>,
}

#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct CsrfEntry {
    pub path: String,
    pub method: String,
    #[serde(default)]
    pub csrf_enforced: bool,
    /// Whether an exemption prefix — rather than CSRF being off entirely — is
    /// why this route is unprotected. Only used to say *which* of the two
    /// happened in the finding's detail.
    #[serde(default)]
    pub exempt: bool,
}

#[derive(Debug, Clone, Default, Deserialize)]
pub struct HeadersDimension {
    #[serde(default)]
    pub entries: Vec<HeaderEntry>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct HeaderEntry {
    pub header: String,
    #[serde(default)]
    pub value: String,
    #[serde(default)]
    pub emitted: bool,
}

#[derive(Debug, Clone, Default, Deserialize)]
pub struct AuthorizationPoliciesDimension {
    #[serde(default)]
    pub entries: Vec<AuthorizationPolicyEntry>,
}

#[derive(Debug, Clone, Deserialize)]
pub struct AuthorizationPolicyEntry {
    pub path: String,
    pub method: String,
    pub action: String,
    pub resource: String,
}

/// The `mtls` dimension: which routes demand a verified client certificate.
///
/// The CA bundle and CRL *paths* are deliberately not in the manifest: moving a
/// file is not a posture change.
#[derive(Debug, Clone, Default, Deserialize)]
pub struct MtlsDimension {
    /// Listener requirement level: `off`, `optional`, or `required`.
    #[serde(default)]
    pub mode: String,
    /// `[server.tls.client_auth] required_paths`, verbatim.
    ///
    /// Carried for the same reason `csrf.exempt_paths` is: the per-route rows
    /// answer whether a *route template* matches a prefix, so a prefix change
    /// that stops covering a concrete request path is invisible in them alone.
    #[serde(default)]
    pub required_paths: Vec<String>,
    #[serde(default)]
    pub entries: Vec<MtlsEntry>,
}

/// One route's declared mTLS requirement.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct MtlsEntry {
    pub path: String,
    pub method: String,
    #[serde(default)]
    pub mtls_required: bool,
}

/// The stable key a route, CSRF entry or authorization binding is compared on.
pub type RouteKey = (String, String);

/// A path with its capture *names* erased but its capture *kinds* kept.
///
/// The router matches on shape, not on what the author called a parameter:
/// `/users/{id}` and `/users/{user_id}` accept exactly the same URLs. Keying on
/// the raw string made a rename read as one route removed and a different one
/// added — both non-blocking — so renaming a capture in the same change that
/// loosened its guard slipped the widening past the gate entirely.
///
/// Kinds stay distinct: `{name}` matches one segment and `{*name}` matches the
/// rest of the path, so those are genuinely different URL sets and must not
/// collapse together.
///
/// Axum's escapes for literal braces are decoded rather than read as captures:
/// `/{{foo}}` is the URL `/{foo}`, and `router.rs`'s conflict matrix mounts it
/// happily beside `/{{bar}}`. Treating each escape as a capture collapsed two
/// distinct public routes into one key, so adding the second raised no finding.
#[must_use]
pub fn normalize_captures(path: &str) -> String {
    let mut out = String::with_capacity(path.len());
    let mut rest = path;
    loop {
        let Some(brace) = rest.find(['{', '}']) else {
            out.push_str(rest);
            return out;
        };
        out.push_str(&rest[..brace]);
        let tail = &rest[brace..];
        if let Some(escaped) = tail.strip_prefix("{{") {
            out.push('{');
            rest = escaped;
            continue;
        }
        if let Some(escaped) = tail.strip_prefix("}}") {
            out.push('}');
            rest = escaped;
            continue;
        }
        if let Some(stray) = tail.strip_prefix('}') {
            // Unbalanced: keep it verbatim rather than inventing a shape, so a
            // malformed path still compares against itself.
            out.push('}');
            rest = stray;
            continue;
        }
        let Some(close) = tail.find('}') else {
            out.push_str(tail);
            return out;
        };
        out.push(if tail[1..close].starts_with('*') {
            CATCH_ALL
        } else {
            CAPTURE
        });
        rest = &tail[close + 1..];
    }
}

/// A single-segment capture (`{id}`) in a normalized path.
///
/// A control character rather than `{}`, because a normalized path also carries
/// *decoded* literal braces: a route whose URL really contains `{}` would
/// otherwise be indistinguishable from a capture, and the two must never share
/// a key.
pub const CAPTURE: char = '\u{1}';

/// A catch-all capture (`{*rest}`), which matches every remaining segment.
pub const CATCH_ALL: char = '\u{2}';

impl MtlsDimension {
    /// Whether the listener requests a client certificate at all.
    ///
    /// An empty `mode` is what a manifest that predates this dimension reads
    /// as, and it means the same thing as `off`.
    #[must_use]
    pub fn requests_certificate(&self) -> bool {
        !(self.mode.is_empty() || self.mode == "off")
    }

    /// Whether the handshake fails without a certificate, which protects every
    /// route whatever `required_paths` holds.
    #[must_use]
    pub fn mandates_certificate(&self) -> bool {
        self.mode == "required"
    }
}

impl MtlsEntry {
    /// `(path, method)` — the same route identity the other dimensions key on.
    #[must_use]
    pub fn key(&self) -> RouteKey {
        (normalize_captures(&self.path), self.method.clone())
    }
}

impl RouteEntry {
    /// The guard is a declared layer, not a proven one.
    #[must_use]
    pub fn is_declared(&self) -> bool {
        self.provenance == "declared"
    }

    /// `(path, method)` — the identity of a mounted route. Handler name and
    /// source location are not part of it.
    #[must_use]
    pub fn key(&self) -> RouteKey {
        (normalize_captures(&self.path), self.method.clone())
    }

    /// Roles as a set. `#[secured("a", "b")]` admits *either* role, so ordering
    /// carries no meaning and re-ordering must never read as a change.
    #[must_use]
    pub fn role_set(&self) -> BTreeSet<String> {
        self.roles.iter().cloned().collect()
    }

    /// Scopes as a set. `__check_secured_scopes` requires *all* of them.
    #[must_use]
    pub fn scope_set(&self) -> BTreeSet<String> {
        self.scopes.iter().cloned().collect()
    }

    /// A one-line human rendering of this route's posture, e.g.
    /// `gated (roles: admin)` or `public`.
    #[must_use]
    pub fn posture_label(&self) -> String {
        let mut label = if self.classification.is_empty() {
            "unclassified".to_owned()
        } else {
            self.classification.clone()
        };
        let mut parts = Vec::new();
        if !self.roles.is_empty() {
            let mut roles: Vec<&str> = self.roles.iter().map(String::as_str).collect();
            roles.sort_unstable();
            parts.push(format!("roles: {}", roles.join(", ")));
        }
        if !self.scopes.is_empty() {
            let mut scopes: Vec<&str> = self.scopes.iter().map(String::as_str).collect();
            scopes.sort_unstable();
            parts.push(format!("scopes: {}", scopes.join(", ")));
        }
        if self.policy {
            parts.push("policy".to_owned());
        }
        if !parts.is_empty() {
            let _ = write!(label, " ({})", parts.join("; "));
        }
        label
    }
}

/// Whether a classification means "anyone can reach this route".
///
/// The vocabulary is the one `routes audit` assigns (see
/// [`AuditRoute::is_unclassified`](crate::routes_audit::AuditRoute::is_unclassified)):
/// `gated`, `public`, `framework`, `unclassified`. Anything else — including a
/// value a newer emitter might add — reads as open, which is the safe
/// direction: it over-reports rather than missing a widening.
#[must_use]
pub fn is_open(classification: &str) -> bool {
    // An empty or unknown classification is treated as open on purpose: it is
    // what `routes audit` calls `unclassified`, and the safe reading of "we
    // could not prove a guard" is "there is no guard".
    !matches!(classification, "gated" | "framework")
}

impl PostureManifest {
    /// Parse a manifest, refusing a schema this CLI cannot read.
    ///
    /// `path` is only used to make the diagnostics point somewhere.
    pub fn parse(json: &str, path: &str) -> Result<Self, ManifestError> {
        let mut manifest: Self =
            serde_json::from_str(json).map_err(|e| ManifestError::Malformed {
                path: path.to_owned(),
                message: e.to_string(),
            })?;
        if manifest.schema_version < MIN_SCHEMA_VERSION
            || manifest.schema_version > MAX_SCHEMA_VERSION
        {
            return Err(ManifestError::UnsupportedSchema {
                path: path.to_owned(),
                found: manifest.schema_version,
            });
        }
        if manifest.schema_version < 5 {
            manifest.legacy_rows = drop_legacy_probe_rows(&mut manifest.dimensions);
        }
        // No app mounts one method on one path twice (Axum panics), so a
        // duplicate row is a corrupt manifest. A merge of two rows cannot show
        // two alternative guards, and it can hide a widening (#2472).
        let d = &manifest.dimensions;
        refuse_duplicates(
            path,
            "routes",
            d.routes.entries.iter().map(|e| (e.key(), &e.path)),
        )?;
        refuse_duplicates(
            path,
            "csrf",
            d.csrf
                .entries
                .iter()
                .map(|e| ((normalize_captures(&e.path), e.method.clone()), &e.path)),
        )?;
        refuse_duplicates(
            path,
            "mtls",
            d.mtls.entries.iter().map(|e| (e.key(), &e.path)),
        )?;
        manifest.build.features = crate::routes_audit::normalize_features(&manifest.build.features);
        Ok(manifest)
    }

    /// Read and parse a manifest from disk.
    pub fn read(path: &str) -> Result<Self, ManifestError> {
        let json = std::fs::read_to_string(path).map_err(|source| ManifestError::Io {
            path: path.to_owned(),
            source,
        })?;
        Self::parse(&json, path)
    }

    /// The canonical, security-relevant projection of this manifest: one sorted
    /// line per fact, with cosmetic fields (handler name, source location)
    /// dropped and set-valued fields sorted.
    ///
    /// This is what [`Self::posture_digest`] hashes, so two manifests that
    /// differ only cosmetically hash identically.
    #[must_use]
    /// Paths are normalized here for the same reason the differ normalizes
    /// them: the router matches on shape, so a renamed capture is not a posture
    /// change. Hashing the raw path made the scaffolded staleness check reject
    /// a posture-neutral refactor — the regenerate-and-commit chore that check
    /// exists to avoid.
    pub fn projection(&self) -> String {
        let mut lines: Vec<String> = Vec::new();
        // Only a non-default build, so a manifest from before v5 keeps its
        // digest (#2472).
        if !self.build.is_default() {
            lines.push(format!("build\t{}", self.build.canonical()));
        }
        for r in self
            .dimensions
            .routes
            .entries
            .iter()
            .chain(&self.legacy_rows)
        {
            let roles: Vec<String> = r.role_set().into_iter().collect();
            let scopes: Vec<String> = r.scope_set().into_iter().collect();
            // Only a declared guard, so a manifest without one keeps its digest.
            let declared = if r.is_declared() {
                "\tprov=declared"
            } else {
                ""
            };
            lines.push(format!(
                "route\t{}\t{}\t{}\troles={}\tscopes={}\tpolicy={}{declared}",
                escape_field(&normalize_captures(&r.path)),
                escape_field(&r.method),
                escape_field(&r.classification),
                escape_list(&roles),
                escape_list(&scopes),
                r.policy
            ));
        }
        if !self.dimensions.csrf.exempt_paths.is_empty() {
            let mut prefixes = self.dimensions.csrf.exempt_paths.clone();
            prefixes.sort();
            prefixes.dedup();
            lines.push(format!("csrf-exempt\t{}", escape_list(&prefixes)));
        }
        for c in &self.dimensions.csrf.entries {
            lines.push(format!(
                "csrf\t{}\t{}\tenforced={}",
                escape_field(&normalize_captures(&c.path)),
                escape_field(&c.method),
                c.csrf_enforced
            ));
        }
        for h in &self.dimensions.security_headers.entries {
            lines.push(format!(
                "header\t{}\temitted={}\tvalue={}",
                escape_field(&h.header),
                h.emitted,
                escape_field(&h.value)
            ));
        }
        for a in &self.dimensions.authorization_policies.entries {
            lines.push(format!(
                "authz\t{}\t{}\t{}\t{}",
                escape_field(&normalize_captures(&a.path)),
                escape_field(&a.method),
                escape_field(&a.action),
                escape_field(&a.resource)
            ));
        }
        // mTLS (#1640). Only the *required* rows are projected, so a v3
        // manifest (no `mtls` dimension) and a v4 manifest of the same app with
        // client auth off hash identically — the schema bump alone is not a
        // posture change. The mode and prefixes are projected only when the
        // listener actually requests certificates, for the same reason.
        if self.dimensions.mtls.requests_certificate() {
            lines.push(format!(
                "mtls-mode\t{}",
                escape_field(&self.dimensions.mtls.mode)
            ));
            if !self.dimensions.mtls.required_paths.is_empty() {
                let mut prefixes = self.dimensions.mtls.required_paths.clone();
                prefixes.sort();
                prefixes.dedup();
                lines.push(format!("mtls-required-paths\t{}", escape_list(&prefixes)));
            }
        }
        for m in &self.dimensions.mtls.entries {
            if m.mtls_required {
                lines.push(format!(
                    "mtls\t{}\t{}",
                    escape_field(&normalize_captures(&m.path)),
                    escape_field(&m.method)
                ));
            }
        }
        lines.sort();
        lines.dedup();
        lines.join("\n")
    }

    /// SHA-256 of [`Self::projection`], lower-case hex.
    ///
    /// This is the number a release records and `posture verify` re-derives: it
    /// answers "is the posture in this file the posture that was acknowledged",
    /// not "are these bytes unmodified" (the attestation answers that).
    #[must_use]
    pub fn posture_digest(&self) -> String {
        hex_digest(self.projection().as_bytes())
    }
}

/// Remove the one duplicate that a pre-v5 emitter wrote, and return the
/// route rows it removed.
///
/// Before v5, the dump listed a built-in health probe even when a user route
/// replaced it. The router mounted only the user route. So a `framework` row
/// beside a non-framework row with the same key is removed, and a csrf or
/// mtls row that exactly copies an earlier row is removed. Any other
/// duplicate stays, and parsing refuses it.
fn drop_legacy_probe_rows(d: &mut Dimensions) -> Vec<RouteEntry> {
    let owned: BTreeSet<RouteKey> = d
        .routes
        .entries
        .iter()
        .filter(|e| e.classification != "framework")
        .map(RouteEntry::key)
        .collect();
    let (dropped, kept) = std::mem::take(&mut d.routes.entries)
        .into_iter()
        .partition(|e| e.classification == "framework" && owned.contains(&e.key()));
    d.routes.entries = kept;
    // Exact copies project to one line each, so the digest does not change.
    dedup_exact(&mut d.csrf.entries);
    dedup_exact(&mut d.mtls.entries);
    dropped
}

/// Keep the first of rows that are exactly equal.
fn dedup_exact<T: PartialEq>(rows: &mut Vec<T>) {
    let mut kept: Vec<T> = Vec::with_capacity(rows.len());
    for row in rows.drain(..) {
        if !kept.contains(&row) {
            kept.push(row);
        }
    }
    *rows = kept;
}

/// Refuse a dimension that lists one `(path, method)` more than once.
fn refuse_duplicates<'a>(
    file: &str,
    dimension: &str,
    rows: impl Iterator<Item = (RouteKey, &'a String)>,
) -> Result<(), ManifestError> {
    let mut seen = BTreeSet::new();
    for ((normalized, method), path) in rows {
        if !seen.insert((normalized, method.clone())) {
            return Err(ManifestError::Malformed {
                path: file.to_owned(),
                message: format!(
                    "{dimension} lists {method} {path} more than once, but a route has one entry"
                ),
            });
        }
    }
    Ok(())
}

/// Escape a field for a delimiter-joined canonical form.
///
/// Route paths, role names, scope names and header values are all
/// app-controlled strings, and both digests in this module join fields with
/// tabs and lines with newlines. Unescaped, one route whose path contains a tab
/// and a newline hashes exactly like a *set* of ordinary routes — so an
/// acknowledgment for the crafted one silently covers the ordinary ones. This
/// is the function that makes the encoding unambiguous.
#[must_use]
pub fn escape_field(value: &str) -> String {
    let mut out = String::with_capacity(value.len());
    for c in value.chars() {
        match c {
            '\\' => out.push_str("\\\\"),
            '\t' => out.push_str("\\t"),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            // The list separator too, so `escape_list` below can join with a
            // bare comma and stay unambiguous.
            ',' => out.push_str("\\,"),
            other => out.push(other),
        }
    }
    out
}

/// Encode a list unambiguously: the element count, then the escaped elements.
///
/// Two collisions this exists to prevent, both reachable from ordinary
/// attribute syntax:
///
/// - Role and scope names are unrestricted string literals — `#[secured("a,b")]`
///   compiles — so joining first and escaping afterwards would make `["a,b"]`
///   and `["a", "b"]` identical. Under OR semantics those are different
///   postures: the second admits two roles. Each element is escaped on its own,
///   so the only bare comma in the result is a separator.
/// - Joining escaped elements still encodes `[]` and `[""]` identically, and
///   those are *also* different postures: `#[secured]` admits every
///   authenticated session, while `#[secured("")]` compares the session's role
///   against `""`. The count prefix separates them, and every other shape of
///   emptiness with them.
#[must_use]
pub fn escape_list(items: &[String]) -> String {
    let joined = items
        .iter()
        .map(|item| escape_field(item))
        .collect::<Vec<_>>()
        .join(",");
    format!("{}:{joined}", items.len())
}

/// Lower-case hex SHA-256 of `bytes`.
#[must_use]
pub fn hex_digest(bytes: &[u8]) -> String {
    let mut hasher = Sha256::new();
    hasher.update(bytes);
    hex::encode(hasher.finalize())
}

#[cfg(test)]
mod tests {
    use super::*;

    fn manifest(routes_json: &str) -> String {
        format!(
            r#"{{
              "schema_version": 3,
              "dimensions": {{
                "routes": {{ "provenance": "provable", "source": "macro", "entries": [{routes_json}] }},
                "csrf": {{ "provenance": "declared", "source": "config", "exempt_paths": [], "entries": [] }},
                "security_headers": {{ "provenance": "declared", "source": "config", "entries": [] }},
                "authorization_policies": {{ "provenance": "provable", "source": "macro", "runtime_caveat": "x", "entries": [] }}
              }},
              "excluded": []
            }}"#
        )
    }

    #[test]
    fn parses_a_v3_manifest_and_ignores_unknown_fields() {
        let json = manifest(
            r#"{"path":"/admin","method":"GET","name":"admin","classification":"gated",
                "roles":["admin"],"scopes":[],"policy":false,"source":"user",
                "location":"src/admin.rs:1","provenance":"provable","future_field":42}"#,
        );
        let m = PostureManifest::parse(&json, "m.json").expect("v3 manifest parses");
        assert_eq!(m.schema_version, 3);
        assert_eq!(m.dimensions.routes.entries.len(), 1);
        assert_eq!(m.dimensions.routes.entries[0].roles, vec!["admin"]);
    }

    #[test]
    fn refuses_a_newer_schema_than_this_cli_understands() {
        let json = manifest("").replace("\"schema_version\": 3", "\"schema_version\": 99");
        let err = PostureManifest::parse(&json, "m.json").expect_err("v99 must be refused");
        assert!(matches!(
            err,
            ManifestError::UnsupportedSchema { found: 99, .. }
        ));
        assert!(err.to_string().contains("upgrade"), "{err}");
    }

    #[test]
    fn refuses_an_older_schema_than_this_cli_understands() {
        let json = manifest("").replace("\"schema_version\": 3", "\"schema_version\": 1");
        assert!(matches!(
            PostureManifest::parse(&json, "m.json"),
            Err(ManifestError::UnsupportedSchema { found: 1, .. })
        ));
    }

    #[test]
    fn malformed_json_names_the_file() {
        let err = PostureManifest::parse("{ not json", "base.json").expect_err("must fail");
        assert!(err.to_string().contains("base.json"), "{err}");
    }

    /// Every manifest before v5 came from a dev build with default features,
    /// so a missing `build` reads as exactly that.
    #[test]
    fn a_manifest_without_a_build_reads_as_the_default_build() {
        let m = PostureManifest::parse(&manifest(""), "m.json").expect("parses");
        assert_eq!(m.build, BuildConfig::default());
        assert!(m.build.is_default());
        assert_eq!(m.build.profile, "dev");
    }

    /// A default build adds nothing to the digest, so a v4 manifest and a v5
    /// one of the same app hash the same and no baseline goes stale.
    #[test]
    fn the_default_build_does_not_move_the_digest() {
        let older = PostureManifest::parse(&manifest(""), "m.json").expect("parses");
        let v5 = PostureManifest::parse(
            &manifest("").replace(
                "\"schema_version\": 3,",
                r#""schema_version": 5, "build": {"profile": "dev", "features": [],
                   "all_features": false, "no_default_features": false},"#,
            ),
            "m.json",
        )
        .expect("parses");
        assert_eq!(older.posture_digest(), v5.posture_digest());
    }

    /// A non-default build is part of the digest: a baseline built under
    /// other flags than CI uses is stale, and the attested digest names the
    /// build it describes.
    #[test]
    fn a_non_default_build_moves_the_digest() {
        let with = |build: &str| {
            PostureManifest::parse(
                &manifest("").replace(
                    "\"schema_version\": 3,",
                    &format!(r#""schema_version": 5, "build": {build},"#),
                ),
                "m.json",
            )
            .expect("parses")
            .posture_digest()
        };
        let dev = with(r#"{"profile": "dev"}"#);
        let release = with(r#"{"profile": "release"}"#);
        let featured = with(r#"{"profile": "release", "features": ["embed-assets"]}"#);
        assert_ne!(dev, release);
        assert_ne!(release, featured);
        // Feature order and duplicates carry no meaning.
        assert_eq!(
            with(r#"{"profile": "release", "features": ["b", "a", "a"]}"#),
            with(r#"{"profile": "release", "features": ["a", "b"]}"#)
        );
    }

    /// No real app mounts one method on one path twice (Axum panics), so a
    /// duplicate key is a corrupt manifest. A merge of two entries cannot
    /// show two alternative guards, and it can hide a widening (#2472).
    #[test]
    fn a_duplicate_route_key_is_refused() {
        let gated =
            r#"{"path":"/a/{id}","method":"GET","classification":"gated","roles":["admin"]}"#;
        let renamed = r#"{"path":"/a/{other}","method":"GET","classification":"public"}"#;
        let err = PostureManifest::parse(&manifest(&format!("{gated},{renamed}")), "m.json")
            .expect_err("a duplicate key must be refused");
        assert!(matches!(err, ManifestError::Malformed { .. }), "{err}");
        assert!(err.to_string().contains("GET /a/{other}"), "{err}");

        // The same path on another method is another route.
        let post = r#"{"path":"/a/{id}","method":"POST","classification":"gated"}"#;
        assert!(PostureManifest::parse(&manifest(&format!("{gated},{post}")), "m.json").is_ok());
    }

    /// Before v5, the dump listed the built-in `GET /health` beside a user
    /// route that replaced it (the router never mounted the probe). Such a
    /// committed baseline must still read, as the user route alone.
    #[test]
    fn a_pre_v5_probe_row_beside_its_user_route_is_dropped() {
        let doc = |version: u32| {
            format!(
                r#"{{"schema_version":{version},"dimensions":{{
                  "routes":{{"entries":[
                    {{"path":"/health","method":"GET","classification":"public"}},
                    {{"path":"/health","method":"GET","classification":"framework"}}]}},
                  "mtls":{{"mode":"required","entries":[
                    {{"path":"/health","method":"GET","mtls_required":true}},
                    {{"path":"/health","method":"GET","mtls_required":true}}]}}
                }}}}"#
            )
        };
        for version in [3, 4] {
            let m = PostureManifest::parse(&doc(version), "m.json").expect("legacy baseline reads");
            assert_eq!(m.dimensions.routes.entries.len(), 1);
            assert_eq!(m.dimensions.routes.entries[0].classification, "public");
            assert_eq!(m.dimensions.mtls.entries.len(), 1);
        }
        // The fixed emitter never writes it, so v5 stays strict.
        assert!(PostureManifest::parse(&doc(5), "m.json").is_err());
    }

    /// A release recorded the digest of the manifest as written, both rows
    /// included. The cleanup is for the diff only, so `posture verify` still
    /// matches that recorded digest.
    #[test]
    fn a_dropped_legacy_row_stays_in_the_digest() {
        let json = r#"{"schema_version":4,"dimensions":{"routes":{"entries":[
            {"path":"/health","method":"GET","classification":"public"},
            {"path":"/health","method":"GET","classification":"framework"}]}}}"#;
        let m = PostureManifest::parse(json, "m.json").expect("legacy baseline reads");
        assert_eq!(m.dimensions.routes.entries.len(), 1);
        assert_eq!(
            m.projection(),
            "route\t/health\tGET\tframework\troles=0:\tscopes=0:\tpolicy=false\n\
             route\t/health\tGET\tpublic\troles=0:\tscopes=0:\tpolicy=false"
        );
    }

    /// Only that one known pattern is forgiven. Two user rows, or two rows
    /// that differ, are still refused in an old manifest.
    #[test]
    fn other_pre_v5_duplicates_are_still_refused() {
        let routes = r#"{"schema_version":4,"dimensions":{"routes":{"entries":[
            {"path":"/a","method":"GET","classification":"public"},
            {"path":"/a","method":"GET","classification":"gated","roles":["admin"]}]}}}"#;
        assert!(PostureManifest::parse(routes, "m.json").is_err());
        let mtls = r#"{"schema_version":4,"dimensions":{"mtls":{"mode":"required","entries":[
            {"path":"/a","method":"GET","mtls_required":true},
            {"path":"/a","method":"GET","mtls_required":false}]}}}"#;
        assert!(PostureManifest::parse(mtls, "m.json").is_err());
    }

    #[test]
    fn a_build_label_names_every_flag() {
        assert_eq!(
            BuildConfig::default().label(),
            "dev profile; default features"
        );
        let build = BuildConfig {
            profile: "release".to_owned(),
            features: vec!["a".to_owned(), "b".to_owned()],
            all_features: true,
            no_default_features: true,
        };
        assert_eq!(
            build.label(),
            "release profile; no default features; all features; features: a, b"
        );
    }

    /// The csrf and mtls rows are one per route too. A duplicate row can
    /// hide a lost requirement behind a kept one, so it is refused.
    #[test]
    fn a_duplicate_csrf_or_mtls_row_is_refused() {
        let doc = |dimension: &str, first: &str, second: &str| {
            format!(
                r#"{{"schema_version":5,"dimensions":{{"{dimension}":{{"entries":[{first},{second}]}}}}}}"#
            )
        };
        // A renamed capture is the same route shape.
        let csrf = doc(
            "csrf",
            r#"{"path":"/pay/{id}","method":"POST","csrf_enforced":true}"#,
            r#"{"path":"/pay/{other}","method":"POST","csrf_enforced":false}"#,
        );
        let mtls = doc(
            "mtls",
            r#"{"path":"/a","method":"GET","mtls_required":true}"#,
            r#"{"path":"/a","method":"GET","mtls_required":false}"#,
        );
        for json in [csrf, mtls] {
            let err = PostureManifest::parse(&json, "m.json").expect_err("must be refused");
            assert!(matches!(err, ManifestError::Malformed { .. }), "{err}");
        }
    }

    #[test]
    fn a_manifest_missing_dimensions_reads_as_empty() {
        let m = PostureManifest::parse(r#"{"schema_version":3}"#, "m.json").expect("parses");
        assert!(m.dimensions.routes.entries.is_empty());
        assert!(m.dimensions.csrf.entries.is_empty());
    }

    /// The digest keys on the same shape the differ does. A renamed capture is
    /// not a posture change, so it must not move the digest — otherwise the
    /// scaffolded staleness check rejects a posture-neutral refactor and turns
    /// it into a regenerate-and-commit chore, which is exactly what that check
    /// promises not to do.
    #[test]
    fn renaming_a_capture_does_not_move_the_digest() {
        let with = |path: &str| {
            PostureManifest::parse(
                &manifest(&format!(
                    r#"{{"path":"{path}","method":"GET","name":"h","classification":"gated",
                        "roles":["admin"],"scopes":[],"policy":false,"location":"src/a.rs:1"}}"#
                )),
                "m",
            )
            .expect("fixture parses")
            .posture_digest()
        };

        assert_eq!(with("/users/{id}"), with("/users/{user_id}"));
        assert_ne!(
            with("/users/{id}"),
            with("/users/{*rest}"),
            "a catch-all is a different URL set, not a rename"
        );
    }

    #[test]
    fn projection_drops_cosmetic_fields() {
        let a = PostureManifest::parse(
            &manifest(
                r#"{"path":"/a","method":"GET","name":"old_name","classification":"gated",
                    "roles":["admin"],"scopes":[],"policy":false,"location":"src/a.rs:10"}"#,
            ),
            "a",
        )
        .unwrap();
        let b = PostureManifest::parse(
            &manifest(
                r#"{"path":"/a","method":"GET","name":"new_name","classification":"gated",
                    "roles":["admin"],"scopes":[],"policy":false,"location":"src/other.rs:400"}"#,
            ),
            "b",
        )
        .unwrap();
        assert_eq!(a.posture_digest(), b.posture_digest());
    }

    #[test]
    fn projection_is_order_insensitive_for_roles_and_entries() {
        let a = PostureManifest::parse(
            &manifest(
                r#"{"path":"/a","method":"GET","classification":"gated","roles":["admin","editor"],"scopes":["read","write"],"policy":false},
                   {"path":"/b","method":"GET","classification":"public","roles":[],"scopes":[],"policy":false}"#,
            ),
            "a",
        )
        .unwrap();
        let b = PostureManifest::parse(
            &manifest(
                r#"{"path":"/b","method":"GET","classification":"public","roles":[],"scopes":[],"policy":false},
                   {"path":"/a","method":"GET","classification":"gated","roles":["editor","admin"],"scopes":["write","read"],"policy":false}"#,
            ),
            "b",
        )
        .unwrap();
        assert_eq!(a.posture_digest(), b.posture_digest());
    }

    /// `#[secured("a,b")]` is legal — role and scope names are unrestricted
    /// string literals — so joining a list with commas and escaping only the
    /// joined string makes `["a,b"]` and `["a", "b"]` hash identically. Under OR
    /// semantics those are different postures: the second admits two roles.
    #[test]
    fn a_comma_inside_a_role_name_does_not_forge_a_second_role() {
        let one_odd_role = PostureManifest::parse(
            &manifest(
                r#"{"path":"/a","method":"GET","classification":"gated","roles":["a,b"],"scopes":[],"policy":false}"#,
            ),
            "a",
        )
        .unwrap();
        let two_roles = PostureManifest::parse(
            &manifest(
                r#"{"path":"/a","method":"GET","classification":"gated","roles":["a","b"],"scopes":[],"policy":false}"#,
            ),
            "b",
        )
        .unwrap();
        assert_ne!(one_odd_role.posture_digest(), two_roles.posture_digest());
    }

    #[test]
    fn a_comma_inside_a_scope_name_does_not_forge_a_second_scope() {
        let one = PostureManifest::parse(
            &manifest(
                r#"{"path":"/a","method":"GET","classification":"gated","roles":[],"scopes":["x,y"],"policy":false}"#,
            ),
            "a",
        )
        .unwrap();
        let two = PostureManifest::parse(
            &manifest(
                r#"{"path":"/a","method":"GET","classification":"gated","roles":[],"scopes":["x","y"],"policy":false}"#,
            ),
            "b",
        )
        .unwrap();
        assert_ne!(one.posture_digest(), two.posture_digest());
    }

    /// `#[secured("")]` compiles, and it is *not* `#[secured]`: with a role
    /// list of `[""]` the runtime compares the session's role against `""`,
    /// while an empty list skips the comparison and admits every authenticated
    /// session. Joining escaped elements encodes both as the empty string, so
    /// the encoding needs to carry the element count.
    #[test]
    fn an_empty_role_list_is_not_a_list_holding_an_empty_role() {
        let none = PostureManifest::parse(
            &manifest(
                r#"{"path":"/a","method":"GET","classification":"gated","roles":[],"scopes":[],"policy":false}"#,
            ),
            "a",
        )
        .unwrap();
        let one_empty = PostureManifest::parse(
            &manifest(
                r#"{"path":"/a","method":"GET","classification":"gated","roles":[""],"scopes":[],"policy":false}"#,
            ),
            "b",
        )
        .unwrap();
        assert_ne!(none.posture_digest(), one_empty.posture_digest());
    }

    #[test]
    fn the_list_encoding_distinguishes_every_shape_of_emptiness() {
        let empty: Vec<String> = vec![];
        let one_empty = vec![String::new()];
        let two_empty = vec![String::new(), String::new()];
        let one_value = vec!["a".to_owned()];
        let encodings = [
            escape_list(&empty),
            escape_list(&one_empty),
            escape_list(&two_empty),
            escape_list(&one_value),
            escape_list(&["a,b".to_owned()]),
            escape_list(&["a".to_owned(), "b".to_owned()]),
        ];
        for (i, a) in encodings.iter().enumerate() {
            for (j, b) in encodings.iter().enumerate() {
                assert!(
                    i == j || a != b,
                    "encodings {i} and {j} collide: {a:?} == {b:?}"
                );
            }
        }
    }

    #[test]
    fn escaping_is_reversible_enough_to_keep_fields_distinct() {
        // The pairs that would collide under a weaker scheme.
        assert_ne!(escape_field("a\tb"), escape_field("a\\tb"));
        assert_ne!(escape_field("a,b"), escape_list(&["a".into(), "b".into()]));
        assert_ne!(
            escape_list(&["a,b".into()]),
            escape_list(&["a".into(), "b".into()])
        );
    }

    #[test]
    fn a_real_posture_change_moves_the_digest() {
        let gated = PostureManifest::parse(
            &manifest(
                r#"{"path":"/a","method":"GET","classification":"gated","roles":["admin"],"scopes":[],"policy":false}"#,
            ),
            "a",
        )
        .unwrap();
        let public = PostureManifest::parse(
            &manifest(
                r#"{"path":"/a","method":"GET","classification":"public","roles":[],"scopes":[],"policy":false}"#,
            ),
            "b",
        )
        .unwrap();
        assert_ne!(gated.posture_digest(), public.posture_digest());
    }

    #[test]
    fn open_classifications_are_the_ones_without_a_proven_guard() {
        assert!(is_open("public"));
        assert!(is_open("unclassified"));
        assert!(is_open(""));
        assert!(!is_open("gated"));
        assert!(!is_open("framework"));
    }

    #[test]
    fn posture_label_reads_like_the_attribute_it_came_from() {
        let entry: RouteEntry = serde_json::from_str(
            r#"{"path":"/a","method":"GET","classification":"gated","roles":["editor","admin"],"scopes":["posts:write"],"policy":true}"#,
        )
        .unwrap();
        assert_eq!(
            entry.posture_label(),
            "gated (roles: admin, editor; scopes: posts:write; policy)"
        );
    }
}