1use std::collections::HashSet;
14use std::ops::{Deref, DerefMut};
15use std::path::Path;
16use std::sync::atomic::{AtomicU64, Ordering};
17
18use crate::merkle_spec_hash::hex_to_array;
19use crate::{CredentialHash, VerifiedFinding};
20
21mod metadata;
24use metadata::*;
25
26mod glob;
30use glob::{normalize_path, pattern_matches_path, PathGlobIndex};
31
32static NEXT_OBSERVED_PATHS_ID: AtomicU64 = AtomicU64::new(1);
33
34#[derive(Debug)]
42pub struct ObservedPaths {
43 values: Vec<String>,
44 instance_id: u64,
45 mutation_epoch: AtomicU64,
46}
47
48impl ObservedPaths {
49 fn new(values: Vec<String>) -> Self {
50 Self {
51 values,
52 instance_id: NEXT_OBSERVED_PATHS_ID.fetch_add(1, Ordering::Relaxed),
53 mutation_epoch: AtomicU64::new(0),
54 }
55 }
56
57 pub(crate) fn instance_id(&self) -> u64 {
58 self.instance_id
59 }
60
61 pub(crate) fn mutation_epoch(&self) -> u64 {
62 self.mutation_epoch.load(Ordering::Relaxed)
63 }
64}
65
66impl Default for ObservedPaths {
67 fn default() -> Self {
68 Self::new(Vec::new())
69 }
70}
71
72impl Clone for ObservedPaths {
73 fn clone(&self) -> Self {
74 Self::new(self.values.clone())
75 }
76}
77
78impl Deref for ObservedPaths {
79 type Target = Vec<String>;
80
81 fn deref(&self) -> &Self::Target {
82 &self.values
83 }
84}
85
86impl DerefMut for ObservedPaths {
87 fn deref_mut(&mut self) -> &mut Self::Target {
88 self.mutation_epoch.fetch_add(1, Ordering::Relaxed);
89 &mut self.values
90 }
91}
92
93impl AsRef<[String]> for ObservedPaths {
94 fn as_ref(&self) -> &[String] {
95 &self.values
96 }
97}
98
99impl serde::Serialize for ObservedPaths {
100 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
101 self.values.serialize(serializer)
102 }
103}
104
105impl From<Vec<String>> for ObservedPaths {
106 fn from(values: Vec<String>) -> Self {
107 Self::new(values)
108 }
109}
110
111impl FromIterator<String> for ObservedPaths {
112 fn from_iter<T: IntoIterator<Item = String>>(iter: T) -> Self {
113 Self::new(iter.into_iter().collect())
114 }
115}
116
117impl IntoIterator for ObservedPaths {
118 type Item = String;
119 type IntoIter = std::vec::IntoIter<String>;
120
121 fn into_iter(self) -> Self::IntoIter {
122 self.values.into_iter()
123 }
124}
125
126impl<'a> IntoIterator for &'a ObservedPaths {
127 type Item = &'a String;
128 type IntoIter = std::slice::Iter<'a, String>;
129
130 fn into_iter(self) -> Self::IntoIter {
131 self.values.iter()
132 }
133}
134
135impl PartialEq for ObservedPaths {
136 fn eq(&self, other: &Self) -> bool {
137 self.values == other.values
138 }
139}
140
141impl<T: AsRef<str>> PartialEq<Vec<T>> for ObservedPaths {
142 fn eq(&self, other: &Vec<T>) -> bool {
143 self.values.len() == other.len()
144 && self
145 .values
146 .iter()
147 .zip(other)
148 .all(|(left, right)| left == right.as_ref())
149 }
150}
151
152#[derive(Debug, Clone, PartialEq, Eq)]
172pub enum AllowlistRuleKind {
173 Hash(CredentialHash),
175 Detector(String),
177 Path(String),
179}
180
181#[derive(Debug, Clone)]
183pub struct AllowlistRule {
184 pub line_number: usize,
186 pub entry: String,
188 pub kind: AllowlistRuleKind,
190 pub matches: std::sync::Arc<std::sync::atomic::AtomicUsize>,
192}
193
194#[derive(Debug, Clone, PartialEq, Eq)]
196pub struct UnusedAllowlistEntry {
197 pub line_number: usize,
199 pub entry: String,
201 pub match_count: usize,
203}
204
205#[derive(Debug, serde::Serialize)]
207pub struct Allowlist {
208 pub credential_hashes: HashSet<CredentialHash>,
210 pub ignored_detectors: HashSet<String>,
212 pub ignored_paths: ObservedPaths,
216 #[serde(skip)]
222 path_index: PathGlobIndex,
223 #[serde(skip)]
226 expired_entries: Vec<ExpiredAllowlistEntry>,
227 #[serde(skip)]
231 policy_violations: Vec<AllowlistPolicyViolation>,
232 #[serde(skip)]
234 pub rules: Vec<AllowlistRule>,
235}
236#[derive(Debug, Clone)]
237struct ExpiredAllowlistEntry {
238 line_number: usize,
239 entry: String,
240 expires: String,
241}
242
243#[derive(Debug, Clone, Copy, Default, serde::Serialize)]
244struct AllowlistMetadataPolicy {
245 require_reason: bool,
246 require_approved_by: bool,
247 max_expires_days: Option<u64>,
248}
249
250impl AllowlistMetadataPolicy {
251 fn is_enforced(self) -> bool {
252 self.require_reason || self.require_approved_by || self.max_expires_days.is_some()
253 }
254}
255
256#[derive(Debug, Clone)]
257struct AllowlistPolicyViolation {
258 line_number: usize,
259 entry: String,
260 field: &'static str,
261 detail: String,
262}
263
264impl Allowlist {
265 pub(crate) fn empty() -> Self {
276 let ignored_paths = ObservedPaths::default();
277 Self {
278 credential_hashes: HashSet::new(),
279 ignored_detectors: HashSet::new(),
280 path_index: PathGlobIndex::build(&ignored_paths),
281 ignored_paths,
282 expired_entries: Vec::new(),
283 policy_violations: Vec::new(),
284 rules: Vec::new(),
285 }
286 }
287
288 pub fn load_with_metadata_policy(
290 path: &Path,
291 require_reason: bool,
292 require_approved_by: bool,
293 max_expires_days: Option<u64>,
294 ) -> Result<Self, std::io::Error> {
295 Self::load_with_policy(
296 path,
297 AllowlistMetadataPolicy {
298 require_reason,
299 require_approved_by,
300 max_expires_days,
301 },
302 )
303 }
304
305 fn load_with_policy(
306 path: &Path,
307 policy: AllowlistMetadataPolicy,
308 ) -> Result<Self, std::io::Error> {
309 let bytes = crate::state_file::read_capped(
310 path,
311 crate::state_file::RULE_CONFIG_FILE_BYTES,
312 "allowlist",
313 )?;
314 let contents = String::from_utf8(bytes)
315 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
316 let allowlist = Self::parse_with_policy(&contents, policy);
317 if !allowlist.expired_entries.is_empty() {
318 return Err(allowlist.expired_entries_error(path));
319 }
320 if !allowlist.policy_violations.is_empty() {
321 return Err(allowlist.policy_violations_error(path));
322 }
323 Ok(allowlist)
324 }
325
326 pub fn parse(content: &str) -> Self {
345 Self::parse_with_policy(content, AllowlistMetadataPolicy::default())
346 }
347
348 fn parse_with_policy(content: &str, policy: AllowlistMetadataPolicy) -> Self {
349 let mut al = Self::empty();
350 let today_days = match try_today_days_since_epoch() {
351 Ok(days) => days,
352 Err(detail) => {
353 al.push_policy_violation(1, "<allowlist>", "system_clock", detail);
354 return al;
355 }
356 };
357 let today = yyyy_mm_dd_from_days(today_days);
358 for (line_number, raw_line) in content.lines().enumerate() {
359 let raw_line = raw_line.trim();
360 if raw_line.is_empty() || raw_line.starts_with('#') {
361 continue;
362 }
363 let mut parts = raw_line.splitn(2, ';');
366 let entry = parts.next().unwrap_or("").trim(); let metadata = parts.next().unwrap_or(""); let parsed_meta = parse_inline_metadata(metadata);
369 for key in &parsed_meta.unknown_keys {
370 al.push_policy_violation(
371 line_number + 1,
372 entry,
373 "metadata",
374 format!("unknown key `{key}`; supported keys are reason, expires, approved_by"),
375 );
376 }
377 for detail in &parsed_meta.malformed_tokens {
378 al.push_policy_violation(line_number + 1, entry, "metadata", detail.clone());
379 }
380 if entry.is_empty() {
381 al.push_policy_violation(
382 line_number + 1,
383 entry,
384 "entry",
385 "empty allowlist entry before metadata; add `detector:`, `path:`, `hash:`, or a glob before `;`".to_string(),
386 );
387 continue;
388 }
389
390 if let Some(exp) = parsed_meta.expires.as_deref() {
393 match parse_yyyy_mm_dd_days(exp) {
394 Some(exp_days) if exp_days < today_days => {
395 al.expired_entries.push(ExpiredAllowlistEntry {
396 line_number: line_number + 1,
397 entry: entry.to_string(),
398 expires: exp.to_string(),
399 });
400 tracing::warn!(
401 "allowlist entry expired on {} (today is {}): '{}'",
402 exp,
403 today,
404 entry
405 );
406 continue;
407 }
408 Some(_) => {}
409 None => {
410 al.push_policy_violation(
411 line_number + 1,
412 entry,
413 "expires",
414 "must use YYYY-MM-DD".to_string(),
415 );
416 continue;
417 }
418 }
419 }
420
421 if let Some(hash) = entry.strip_prefix("hash:") {
422 let trimmed = hash.trim();
423 if let Some(valid_hash) = parse_sha256_hex(trimmed) {
424 if !al.metadata_policy_allows(
425 line_number + 1,
426 entry,
427 &parsed_meta,
428 policy,
429 today_days,
430 ) {
431 continue;
432 }
433 al.credential_hashes.insert(valid_hash);
434 al.rules.push(AllowlistRule {
435 line_number: line_number + 1,
436 entry: entry.to_string(),
437 kind: AllowlistRuleKind::Hash(valid_hash),
438 matches: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
439 });
440 log_metadata_audit("hash", trimmed, &parsed_meta);
441 } else {
442 al.push_invalid_entry_violation(
443 line_number + 1,
444 entry,
445 "hash",
446 "must be a 64-character SHA-256 hex digest",
447 );
448 tracing::warn!(
449 "invalid hash allowlist entry at line {}: '{}'",
450 line_number + 1,
451 trimmed
452 );
453 }
454 } else if let Some(detector) = entry.strip_prefix("detector:") {
455 let detector = detector.trim();
456 if detector.is_empty() {
457 al.push_invalid_entry_violation(
458 line_number + 1,
459 entry,
460 "detector",
461 "detector id must not be empty",
462 );
463 tracing::warn!(
464 "invalid detector allowlist entry at line {}: detector id is empty",
465 line_number + 1
466 );
467 } else {
468 if !al.metadata_policy_allows(
469 line_number + 1,
470 entry,
471 &parsed_meta,
472 policy,
473 today_days,
474 ) {
475 continue;
476 }
477 al.ignored_detectors.insert(detector.to_string());
478 al.rules.push(AllowlistRule {
479 line_number: line_number + 1,
480 entry: entry.to_string(),
481 kind: AllowlistRuleKind::Detector(detector.to_string()),
482 matches: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
483 });
484 log_metadata_audit("detector", detector, &parsed_meta);
485 }
486 } else if let Some(path) = entry.strip_prefix("path:") {
487 let path = path.trim();
488 if path.is_empty() {
489 al.push_invalid_entry_violation(
490 line_number + 1,
491 entry,
492 "path",
493 "path glob must not be empty",
494 );
495 tracing::warn!(
496 "invalid path allowlist entry at line {}: glob is empty",
497 line_number + 1
498 );
499 } else {
500 if !al.metadata_policy_allows(
501 line_number + 1,
502 entry,
503 &parsed_meta,
504 policy,
505 today_days,
506 ) {
507 continue;
508 }
509 al.ignored_paths.push(path.to_string());
510 al.rules.push(AllowlistRule {
511 line_number: line_number + 1,
512 entry: entry.to_string(),
513 kind: AllowlistRuleKind::Path(path.to_string()),
514 matches: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
515 });
516 log_metadata_audit("path", path, &parsed_meta);
517 }
518 } else if let Some(bytes) = parse_sha256_hex(entry) {
519 if !al.metadata_policy_allows(
524 line_number + 1,
525 entry,
526 &parsed_meta,
527 policy,
528 today_days,
529 ) {
530 continue;
531 }
532 al.credential_hashes.insert(bytes);
533 al.rules.push(AllowlistRule {
534 line_number: line_number + 1,
535 entry: entry.to_string(),
536 kind: AllowlistRuleKind::Hash(bytes),
537 matches: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
538 });
539 log_metadata_audit("hash", entry, &parsed_meta);
540 } else if let Some((field, detail)) = invalid_bare_entry(entry) {
541 al.push_invalid_entry_violation(line_number + 1, entry, field, detail);
542 tracing::warn!(
543 "invalid allowlist entry at line {}: '{}'",
544 line_number + 1,
545 entry
546 );
547 } else {
548 if !al.metadata_policy_allows(
557 line_number + 1,
558 entry,
559 &parsed_meta,
560 policy,
561 today_days,
562 ) {
563 continue;
564 }
565 al.ignored_paths.push(entry.to_string());
566 al.rules.push(AllowlistRule {
567 line_number: line_number + 1,
568 entry: entry.to_string(),
569 kind: AllowlistRuleKind::Path(entry.to_string()),
570 matches: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
571 });
572 log_metadata_audit("path", entry, &parsed_meta);
573 }
574 }
575 al.path_index = PathGlobIndex::build(&al.ignored_paths);
579 al
580 }
581
582 fn metadata_policy_allows(
583 &mut self,
584 line_number: usize,
585 entry: &str,
586 metadata: &InlineMetadata,
587 policy: AllowlistMetadataPolicy,
588 today_days: i64,
589 ) -> bool {
590 if !policy.is_enforced() {
591 return true;
592 }
593 let mut allowed = true;
594 if policy.require_reason && metadata.reason.as_deref().is_none_or(str::is_empty) {
595 self.push_policy_violation(
596 line_number,
597 entry,
598 "reason",
599 "required by [allowlist].require_reason".to_string(),
600 );
601 allowed = false;
602 }
603 if policy.require_approved_by && metadata.approved_by.as_deref().is_none_or(str::is_empty) {
604 self.push_policy_violation(
605 line_number,
606 entry,
607 "approved_by",
608 "required by [allowlist].require_approved_by".to_string(),
609 );
610 allowed = false;
611 }
612 if let Some(max_expires_days) = policy.max_expires_days {
613 match metadata.expires.as_deref() {
614 Some(expires) if !expires.is_empty() => match parse_yyyy_mm_dd_days(expires) {
615 Some(expires_days) => {
616 let max_days = match i64::try_from(max_expires_days) {
617 Ok(days) => days,
618 Err(error) => {
619 self.push_policy_violation(
620 line_number,
621 entry,
622 "expires",
623 format!(
624 "max_expires_days={max_expires_days} is too large to enforce ({error})"
625 ),
626 );
627 allowed = false;
628 return allowed;
629 }
630 };
631 if expires_days.saturating_sub(today_days) > max_days {
632 self.push_policy_violation(
633 line_number,
634 entry,
635 "expires",
636 format!(
637 "expires={expires} is more than {max_expires_days} days out"
638 ),
639 );
640 allowed = false;
641 }
642 }
643 None => {
644 self.push_policy_violation(
645 line_number,
646 entry,
647 "expires",
648 "must use YYYY-MM-DD when [allowlist].max_expires_days is set"
649 .to_string(),
650 );
651 allowed = false;
652 }
653 },
654 _ => {
655 self.push_policy_violation(
656 line_number,
657 entry,
658 "expires",
659 "required by [allowlist].max_expires_days".to_string(),
660 );
661 allowed = false;
662 }
663 }
664 }
665 allowed
666 }
667
668 fn push_invalid_entry_violation(
669 &mut self,
670 line_number: usize,
671 entry: &str,
672 field: &'static str,
673 detail: &'static str,
674 ) {
675 self.push_policy_violation(line_number, entry, field, detail.to_string());
676 }
677
678 fn push_policy_violation(
679 &mut self,
680 line_number: usize,
681 entry: &str,
682 field: &'static str,
683 detail: String,
684 ) {
685 self.policy_violations.push(AllowlistPolicyViolation {
686 line_number,
687 entry: entry.to_string(),
688 field,
689 detail,
690 });
691 }
692
693 fn expired_entries_error(&self, path: &Path) -> std::io::Error {
694 let first = &self.expired_entries[0];
695 let extra = self.expired_entries.len().saturating_sub(1);
696 let suffix = if extra == 0 {
697 String::new()
698 } else if extra == 1 {
699 " (+1 more expired entry)".to_string()
700 } else {
701 format!(" (+{extra} more expired entries)")
702 };
703 std::io::Error::new(
704 std::io::ErrorKind::InvalidData,
705 format!(
706 "{} contains expired allowlist policy at line {}: '{}' expired on {}{}. \
707 Remove the entry or renew its expires metadata; refusing to scan with stale suppressions.",
708 path.display(),
709 first.line_number,
710 first.entry,
711 first.expires,
712 suffix
713 ),
714 )
715 }
716
717 fn policy_violations_error(&self, path: &Path) -> std::io::Error {
718 let first = &self.policy_violations[0];
719 let extra = self.policy_violations.len().saturating_sub(1);
720 let suffix = if extra == 0 {
721 String::new()
722 } else if extra == 1 {
723 " (+1 more policy violation)".to_string()
724 } else {
725 format!(" (+{extra} more policy violations)")
726 };
727 std::io::Error::new(
728 std::io::ErrorKind::InvalidData,
729 format!(
730 "{} violates allowlist governance at line {}: '{}' missing/invalid {} ({}){}. \
731 Add inline metadata like `; reason=\"...\"; approved_by=\"...\"; expires=YYYY-MM-DD` \
732 or relax the [allowlist] policy in .keyhog.toml; refusing to scan with unapproved suppressions.",
733 path.display(),
734 first.line_number,
735 first.entry,
736 first.field,
737 first.detail,
738 suffix
739 ),
740 )
741 }
742
743 pub(crate) fn is_allowed(&self, finding: &VerifiedFinding) -> bool {
766 let detector_ignored = self.ignored_detectors.contains(&*finding.detector_id);
767
768 let path_ignored = finding.location.file_path.as_ref().is_some_and(|path| {
769 let normalized_path = normalize_path(path);
770 self.path_matches(&normalized_path)
771 });
772
773 let hash_ignored = self.matches_ignored_hash(&finding.credential_hash);
774
775 detector_ignored || path_ignored || hash_ignored
776 }
777
778 pub(crate) fn is_hash_allowed(&self, credential: &str) -> bool {
797 self.matches_ignored_hash_hex(credential)
798 }
799
800 pub(crate) fn is_raw_hash_ignored(&self, hash_hex: &str) -> bool {
802 self.matches_ignored_hash_hex(hash_hex)
803 }
804
805 pub fn is_path_ignored(&self, path: &str) -> bool {
824 let normalized = normalize_path(path);
825 self.path_matches(&normalized)
826 }
827
828 fn path_matches(&self, normalized_path: &str) -> bool {
836 if self.path_index.matches_sources(&self.ignored_paths) {
837 self.path_index.matches(normalized_path)
838 } else {
839 PathGlobIndex::build(&self.ignored_paths).matches(normalized_path)
840 }
841 }
842
843 fn matches_ignored_hash(&self, hash: &CredentialHash) -> bool {
844 self.credential_hashes.contains(hash)
852 }
853
854 fn matches_ignored_hash_hex(&self, hash_hex: &str) -> bool {
855 parse_sha256_hex(hash_hex).is_some_and(|bytes| self.matches_ignored_hash(&bytes))
856 }
857
858 pub fn record_match(&self, finding: &VerifiedFinding) -> bool {
860 let mut matched = false;
861 for rule in &self.rules {
862 match &rule.kind {
863 AllowlistRuleKind::Detector(det) => {
864 if &*finding.detector_id == det {
865 rule.matches.fetch_add(1, Ordering::Relaxed);
866 matched = true;
867 }
868 }
869 AllowlistRuleKind::Hash(h) => {
870 if &finding.credential_hash == h {
871 rule.matches.fetch_add(1, Ordering::Relaxed);
872 matched = true;
873 }
874 }
875 AllowlistRuleKind::Path(p) => {
876 if let Some(path) = finding.location.file_path.as_deref() {
877 let normalized = normalize_path(path);
878 if self.path_matches(&normalized) && pattern_matches_path(p, &normalized) {
879 rule.matches.fetch_add(1, Ordering::Relaxed);
880 matched = true;
881 }
882 }
883 }
884 }
885 }
886 matched
887 }
888
889 pub fn record_path_match(&self, path: &str) -> bool {
891 let normalized = normalize_path(path);
892 let mut matched = false;
893 for rule in &self.rules {
894 if let AllowlistRuleKind::Path(p) = &rule.kind {
895 if pattern_matches_path(p, &normalized) {
896 rule.matches.fetch_add(1, Ordering::Relaxed);
897 matched = true;
898 }
899 }
900 }
901 matched
902 }
903
904 pub fn record_detector_match(&self, detector_id: &str) -> bool {
906 let mut matched = false;
907 for rule in &self.rules {
908 if let AllowlistRuleKind::Detector(d) = &rule.kind {
909 if d == detector_id {
910 rule.matches.fetch_add(1, Ordering::Relaxed);
911 matched = true;
912 }
913 }
914 }
915 matched
916 }
917
918 pub fn record_hash_match(&self, hash: &CredentialHash) -> bool {
920 let mut matched = false;
921 for rule in &self.rules {
922 if let AllowlistRuleKind::Hash(h) = &rule.kind {
923 if h == hash {
924 rule.matches.fetch_add(1, Ordering::Relaxed);
925 matched = true;
926 }
927 }
928 }
929 matched
930 }
931
932 pub fn unused_entries(&self) -> Vec<UnusedAllowlistEntry> {
934 self.rules
935 .iter()
936 .filter_map(|r| {
937 let count = r.matches.load(Ordering::Relaxed);
938 if count == 0 {
939 Some(UnusedAllowlistEntry {
940 line_number: r.line_number,
941 entry: r.entry.clone(),
942 match_count: 0,
943 })
944 } else {
945 None
946 }
947 })
948 .collect()
949 }
950
951 pub fn attributed_match_counts(&self) -> Vec<(String, usize)> {
953 self.rules
954 .iter()
955 .map(|r| (r.entry.clone(), r.matches.load(Ordering::Relaxed)))
956 .collect()
957 }
958}
959
960impl Default for Allowlist {
961 fn default() -> Self {
962 Self::empty()
963 }
964}
965
966impl Clone for Allowlist {
967 fn clone(&self) -> Self {
968 let ignored_paths = self.ignored_paths.clone();
969 Self {
970 credential_hashes: self.credential_hashes.clone(),
971 ignored_detectors: self.ignored_detectors.clone(),
972 path_index: PathGlobIndex::build(&ignored_paths),
973 ignored_paths,
974 expired_entries: self.expired_entries.clone(),
975 policy_violations: self.policy_violations.clone(),
976 rules: self.rules.clone(),
977 }
978 }
979}
980
981fn parse_sha256_hex(input: &str) -> Option<CredentialHash> {
982 hex_to_array(input.trim()).map(CredentialHash::from_bytes)
983}
984
985fn invalid_bare_entry(entry: &str) -> Option<(&'static str, &'static str)> {
986 if entry.contains(':') {
987 return Some((
988 "entry",
989 "entry contains `:` but does not start with a valid prefix (`hash:`, `detector:`, or `path:`); use `path:` for literal path globs containing `:`",
990 ));
991 }
992 let bytes = entry.as_bytes();
993 if bytes.len() == crate::git_lfs::SHA256_HEX_LEN {
994 return Some((
995 "hash",
996 "bare 64-byte entry must be a valid SHA-256 hex digest; use `path:` for a literal 64-byte path glob",
997 ));
998 }
999 if bytes.len() >= 32 && bytes.iter().all(u8::is_ascii_hexdigit) {
1000 return Some((
1001 "hash",
1002 "hex-like bare entry must be exactly a 64-character SHA-256 digest; use `path:` for a literal hex path glob",
1003 ));
1004 }
1005 None
1006}
1007
1008pub(crate) fn allowlist_days_since_epoch_for_test(
1009 now: std::time::SystemTime,
1010) -> Result<i64, String> {
1011 metadata::days_since_epoch_for_test(now)
1012}
1013
1014#[derive(Default, Debug)]
1018struct InlineMetadata {
1019 reason: Option<String>,
1020 expires: Option<String>,
1021 approved_by: Option<String>,
1022 unknown_keys: Vec<String>,
1023 malformed_tokens: Vec<String>,
1024}