1pub(crate) mod config;
41
42pub use config::{Config, RuleConfig};
43
44pub const REPORT_SCHEMA_VERSION: u32 = InternalReporter::JSON_SCHEMA_VERSION;
46
47use crate::_internal::analysis::evidence as internal_evidence;
48use crate::_internal::analysis::outcome::AnalysisOutcome as InternalOutcome;
49use crate::_internal::analysis::state::{
50 AnalysisState as InternalAnalysisState, Confidence as InternalConfidence,
51};
52use crate::_internal::db::cache::{
53 CACHE_FORMAT_VERSION, CACHE_V8_MAGIC, DbCache as InternalDbCache, DbCacheVersioned,
54};
55use crate::_internal::db::cache_file::{
56 MAX_CACHE_DECODE_BYTES, decode_hex_key, is_encrypted_cache_bytes, read_cache_bytes,
57 unprotect_cache_bytes, unprotect_cache_bytes_with_key,
58};
59use crate::_internal::engine::engine::SafeMigrateEngine;
60use crate::_internal::model::function::RoutineKind;
61use crate::_internal::model::relation::RelationKind;
62use crate::_internal::report::reporter::{
63 Reporter as InternalReporter, Verdict as InternalVerdict, compute_verdict,
64};
65use crate::_internal::report::violations::{
66 ObjectKind as InternalObjectKind, OperationKind as InternalOperationKind,
67 ReportFinding as InternalFinding, Violation as InternalViolation,
68 ViolationTier as InternalTier,
69};
70use crate::_internal::rules::registry::{
71 self, RuleConfigurationField as InternalRuleConfigurationField,
72};
73use serde::Serialize;
74use std::fmt;
75use std::io::Read;
76use std::path::Path;
77use std::time::{SystemTime, UNIX_EPOCH};
78use zeroize::{Zeroize, Zeroizing};
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
82#[non_exhaustive]
83pub enum ErrorKind {
84 Configuration,
86 Cache,
88 UnknownRule,
90 Analysis,
92 Report,
94 Sync,
96}
97
98#[derive(Debug)]
103pub struct Error {
104 kind: ErrorKind,
105 message: String,
106 source: Option<Box<dyn std::error::Error + Send + Sync>>,
107}
108
109impl Error {
110 fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
111 Self {
112 kind,
113 message: message.into(),
114 source: None,
115 }
116 }
117
118 fn with_source(
119 kind: ErrorKind,
120 message: impl Into<String>,
121 source: impl std::error::Error + Send + Sync + 'static,
122 ) -> Self {
123 Self {
124 kind,
125 message: message.into(),
126 source: Some(Box::new(source)),
127 }
128 }
129
130 fn with_anyhow_source(
131 kind: ErrorKind,
132 message: impl Into<String>,
133 source: anyhow::Error,
134 ) -> Self {
135 Self {
136 kind,
137 message: message.into(),
138 source: Some(source.into_boxed_dyn_error()),
139 }
140 }
141
142 fn configuration(message: impl Into<String>) -> Self {
143 Self::new(ErrorKind::Configuration, message)
144 }
145
146 fn cache(message: impl Into<String>) -> Self {
147 Self::new(ErrorKind::Cache, message)
148 }
149
150 fn analysis(errors: Vec<String>) -> Self {
151 Self::new(ErrorKind::Analysis, errors.join("; "))
152 }
153
154 pub fn kind(&self) -> ErrorKind {
156 self.kind
157 }
158
159 pub fn message(&self) -> &str {
161 &self.message
162 }
163}
164
165impl fmt::Display for Error {
166 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
167 match self {
168 Self {
169 kind: ErrorKind::Configuration,
170 message,
171 ..
172 } => write!(formatter, "invalid configuration: {message}"),
173 Self {
174 kind: ErrorKind::Cache,
175 message,
176 ..
177 } => write!(formatter, "invalid baseline cache: {message}"),
178 Self {
179 kind: ErrorKind::UnknownRule,
180 message,
181 ..
182 } => write!(formatter, "unknown primary rule: {message}"),
183 Self {
184 kind: ErrorKind::Analysis,
185 message,
186 ..
187 } => write!(formatter, "analysis failed: {message}"),
188 Self {
189 kind: ErrorKind::Report,
190 message,
191 ..
192 } => write!(formatter, "report failed: {message}"),
193 Self {
194 kind: ErrorKind::Sync,
195 message,
196 ..
197 } => write!(formatter, "baseline sync failed: {message}"),
198 }
199 }
200}
201
202impl std::error::Error for Error {
203 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
204 self.source
205 .as_deref()
206 .map(|source| source as &(dyn std::error::Error + 'static))
207 }
208}
209
210pub struct DatabaseUrl(String);
215
216impl DatabaseUrl {
217 pub fn new(value: impl Into<String>) -> Result<Self, Error> {
224 let mut value = value.into();
225 if let Err(error) = crate::_internal::sync::validate_database_url(&value) {
226 value.zeroize();
227 let message = error.to_string();
228 return Err(Error::with_anyhow_source(
229 ErrorKind::Configuration,
230 message,
231 error,
232 ));
233 }
234 Ok(Self(value))
235 }
236
237 fn expose(&self) -> &str {
238 &self.0
239 }
240}
241
242impl fmt::Debug for DatabaseUrl {
243 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
244 formatter.write_str("DatabaseUrl([REDACTED])")
245 }
246}
247
248impl Drop for DatabaseUrl {
249 fn drop(&mut self) {
250 self.0.zeroize();
251 }
252}
253
254pub struct CacheKey([u8; 32]);
258
259impl CacheKey {
260 pub fn from_bytes(value: [u8; 32]) -> Self {
262 Self(value)
263 }
264
265 pub fn from_hex(value: &str) -> Result<Self, Error> {
272 decode_hex_key(value.trim())
273 .map(Self)
274 .map_err(|error| Error::configuration(error.to_string()))
275 }
276
277 fn expose(&self) -> &[u8; 32] {
278 &self.0
279 }
280}
281
282impl fmt::Debug for CacheKey {
283 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
284 formatter.write_str("CacheKey([REDACTED])")
285 }
286}
287
288impl Drop for CacheKey {
289 fn drop(&mut self) {
290 self.0.zeroize();
291 }
292}
293
294impl From<[u8; 32]> for CacheKey {
295 fn from(value: [u8; 32]) -> Self {
296 Self::from_bytes(value)
297 }
298}
299
300#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
302#[non_exhaustive]
303pub enum Confidence {
304 Exact,
306 Tainted,
308}
309
310#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
312#[non_exhaustive]
313pub enum Tier {
314 Tier1,
316 Tier2,
318 Tier3,
320}
321
322#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
324#[non_exhaustive]
325pub enum OperationKind {
326 DropColumn,
328 DropTable,
330 DropIndex,
332 DropView,
334 DropMaterializedView,
336 DropFunction,
338 DropProcedure,
340 DropSchema,
342 DropDatabase,
344 DropSequence,
346 DropDomain,
348 DropType,
350 DropPublication,
352 DropTrigger,
354 DropPolicy,
356 AddColumn,
358 AlterColumnType,
360 AddConstraint,
362 CreateIndex,
364 CreateTable,
366 CreateView,
368 CreateFunction,
370 CreateProcedure,
372 AlterFunction,
374 AlterProcedure,
376 RefreshMaterializedView,
378 AttachPartition,
380 DetachPartition,
382 VacuumFull,
384 LockTable,
386 TruncateTable,
388 Grant,
390 RevokeGrant,
392 AlterType,
394 CreateTrigger,
396 CreatePolicy,
398 DisableTrigger,
400 EnableTrigger,
402 RenameTable,
404 RenameColumn,
406 Rename,
408 OpaqueSql,
410 CreateSchema,
412 SetDefault,
414 CreateSequence,
416 CreateDomain,
418 AlterSchema,
420 Conflict,
422 Irreversible,
424 UnresolvedReference,
426 Other(String),
428}
429
430#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
432#[non_exhaustive]
433pub enum ObjectKind {
434 Table,
436 Index,
438 View,
440 MaterializedView,
442 Function,
444 Procedure,
446 Trigger,
448 Sequence,
450 Schema,
452 Role,
454 Publication,
456 Subscription,
458 Database,
460 Domain,
462 Policy,
464 Type,
466 Opaque,
468 Unknown,
470}
471
472#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
474#[non_exhaustive]
475pub enum Verdict {
476 #[serde(rename = "HALT")]
478 Halt,
479 #[serde(rename = "CAUTIOUS")]
481 Cautious,
482 #[serde(rename = "SAFE WITH RISK")]
484 SafeWithRisk,
485 #[serde(rename = "SAFE")]
487 Safe,
488}
489
490impl Verdict {
491 pub fn as_str(self) -> &'static str {
493 match self {
494 Self::Halt => "HALT",
495 Self::Cautious => "CAUTIOUS",
496 Self::SafeWithRisk => "SAFE WITH RISK",
497 Self::Safe => "SAFE",
498 }
499 }
500}
501
502#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize)]
504#[non_exhaustive]
505pub struct FindingSummary {
506 pub total: usize,
508 pub tier1: usize,
510 pub tier2: usize,
512 pub tier3: usize,
514}
515
516#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
518#[serde(rename_all = "snake_case")]
519#[non_exhaustive]
520pub enum EvidenceCode {
521 BaselineUnavailable,
523 BaselineStale,
525 CatalogCoverageIncomplete,
527 UnsupportedStatement,
529 UnsupportedSemantics,
531 UnresolvedReference,
533 UnknownObjectState,
535 TransactionStateUnknown,
537 UnmodeledState,
539}
540
541#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
543#[serde(rename_all = "snake_case")]
544#[non_exhaustive]
545pub enum EvidenceScope {
546 Statement,
548 Chain,
550}
551
552#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
554#[non_exhaustive]
555pub struct EvidenceLocation {
556 pub file: String,
558 pub statement_index: usize,
560}
561
562#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
564#[non_exhaustive]
565pub struct Evidence {
566 pub code: EvidenceCode,
568 pub scope: EvidenceScope,
570 pub summary: String,
572 #[serde(skip_serializing_if = "Option::is_none")]
574 pub location: Option<EvidenceLocation>,
575}
576
577#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
579#[non_exhaustive]
580pub struct SourceLocation {
581 pub file: String,
583 pub line: usize,
585 pub column: usize,
587}
588
589#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
591#[non_exhaustive]
592pub struct Finding {
593 pub rule_id: String,
595 pub operation_kind: OperationKind,
597 pub object_kind: ObjectKind,
599 pub object_name: String,
601 pub tier: Tier,
603 pub reason: String,
605 pub recipe: String,
607 pub dedup_key: Option<String>,
609 pub sql: Option<String>,
611 #[serde(rename = "fk_dependency_related")]
613 pub foreign_key_dependency_related: bool,
614 #[serde(skip_serializing_if = "Option::is_none")]
616 pub rule_title: Option<String>,
617 #[serde(skip_serializing_if = "Option::is_none")]
619 pub rule_summary: Option<String>,
620 #[serde(skip_serializing_if = "Option::is_none")]
622 pub impact: Option<String>,
623 #[serde(skip_serializing_if = "Option::is_none")]
625 pub location: Option<SourceLocation>,
626 #[serde(skip_serializing_if = "Option::is_none")]
628 pub statement_index: Option<usize>,
629}
630
631#[derive(Debug, Clone, PartialEq, Eq)]
633pub struct Migration {
634 filename: String,
635 sql: String,
636}
637
638impl Migration {
639 pub fn new(filename: impl Into<String>, sql: impl Into<String>) -> Self {
641 Self {
642 filename: filename.into(),
643 sql: sql.into(),
644 }
645 }
646
647 pub fn filename(&self) -> &str {
649 &self.filename
650 }
651
652 pub fn sql(&self) -> &str {
654 &self.sql
655 }
656}
657
658#[derive(Clone)]
660pub struct AnalysisOutcome {
661 findings: Vec<Finding>,
662 confidence: Confidence,
663 evidence: Vec<Evidence>,
664 baseline: BaselineReport,
665 inner: InternalOutcome<InternalFinding>,
666}
667
668impl fmt::Debug for AnalysisOutcome {
669 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
670 formatter
671 .debug_struct("AnalysisOutcome")
672 .field("findings", &self.findings)
673 .field("confidence", &self.confidence)
674 .field("evidence", &self.evidence)
675 .field("baseline", &self.baseline)
676 .finish()
677 }
678}
679
680impl AnalysisOutcome {
681 pub fn findings(&self) -> &[Finding] {
683 &self.findings
684 }
685
686 pub fn confidence(&self) -> Confidence {
688 self.confidence
689 }
690
691 pub fn evidence(&self) -> &[Evidence] {
693 &self.evidence
694 }
695
696 pub fn baseline(&self) -> &BaselineReport {
698 &self.baseline
699 }
700
701 pub fn should_halt(&self) -> bool {
703 self.verdict() == Verdict::Halt
704 }
705
706 pub fn verdict(&self) -> Verdict {
708 compute_verdict(&self.violations()).into()
709 }
710
711 pub fn recommendation(&self) -> &'static str {
713 compute_verdict(&self.violations()).recommendation(&self.inner.confidence)
714 }
715
716 pub fn summary(&self) -> FindingSummary {
718 self.findings.iter().fold(
719 FindingSummary {
720 total: self.findings.len(),
721 ..FindingSummary::default()
722 },
723 |mut summary, finding| {
724 match finding.tier {
725 Tier::Tier1 => summary.tier1 += 1,
726 Tier::Tier2 => summary.tier2 += 1,
727 Tier::Tier3 => summary.tier3 += 1,
728 }
729 summary
730 },
731 )
732 }
733
734 pub fn json(&self) -> serde_json::Value {
736 let mut report = InternalReporter::json_outcome_with_locations(&self.inner);
737 report["baseline"] = serde_json::to_value(&self.baseline)
738 .expect("API-owned baseline report is always serializable");
739 report
740 }
741
742 pub fn markdown(&self) -> String {
744 let mut report = InternalReporter::markdown_outcome(&self.inner);
745 report.push_str("\n## Baseline\n\n");
746 report.push_str(&format!(
747 "- **Status:** `{}`\n- **Automatic sync:** `{}`\n",
748 self.baseline.status.label(),
749 self.baseline.auto_sync.label()
750 ));
751 if let Some(source_database) = &self.baseline.source_database {
752 report.push_str(&format!(
753 "- **Source database:** `{}`\n",
754 markdown_inline_code(source_database)
755 ));
756 }
757 if let Some(schemas) = &self.baseline.schemas {
758 report.push_str(&format!(
759 "- **Schemas:** `{}`\n",
760 markdown_inline_code(&schemas.join(", "))
761 ));
762 }
763 report.push_str(&format!(
764 "- **Observed lock timeout:** `{}`\n- **Observed statement timeout:** `{}`\n",
765 format_timeout(self.baseline.observed_settings.lock_timeout_ms),
766 format_timeout(self.baseline.observed_settings.statement_timeout_ms)
767 ));
768 report
769 }
770
771 pub fn print_human(&self) -> bool {
773 InternalReporter::print_outcome(&self.inner)
774 }
775
776 pub fn run_interactive(&self) -> Result<(), Error> {
778 crate::_internal::report::interactive::run_interactive(
779 &self.violations(),
780 &self.inner.confidence,
781 )
782 .map_err(|error| {
783 let message = error.to_string();
784 Error::with_anyhow_source(ErrorKind::Report, message, error)
785 })
786 }
787
788 pub fn with_evidence(mut self, code: EvidenceCode, scope: EvidenceScope) -> Self {
793 self.inner = self
794 .inner
795 .with_evidence(internal_evidence::EvidenceRecord::new(
796 code.into(),
797 scope.into(),
798 ));
799 self.evidence = self.inner.evidence.iter().map(Evidence::from).collect();
800 self.confidence = self.inner.confidence.clone().into();
801 self
802 }
803
804 pub fn with_auto_sync_status(mut self, status: AutoSyncStatus) -> Self {
806 self.baseline.auto_sync = status;
807 self
808 }
809
810 fn violations(&self) -> Vec<InternalViolation> {
811 self.inner
812 .findings
813 .iter()
814 .map(|finding| finding.violation.clone())
815 .collect()
816 }
817}
818
819#[derive(Clone)]
821pub struct Baseline {
822 inner: InternalDbCache,
823 available: bool,
824 encrypted: bool,
825 format_version: Option<u32>,
826 path: Option<std::path::PathBuf>,
827}
828
829impl fmt::Debug for Baseline {
830 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
831 formatter
832 .debug_struct("Baseline")
833 .field("inspection", &self.inspect())
834 .finish()
835 }
836}
837
838impl Default for Baseline {
839 fn default() -> Self {
840 Self::unavailable()
841 }
842}
843
844impl Baseline {
845 pub fn unavailable() -> Self {
847 Self {
848 inner: InternalDbCache::new(),
849 available: false,
850 encrypted: false,
851 format_version: None,
852 path: None,
853 }
854 }
855
856 pub fn load(path: &Path, config: &Config) -> Result<Self, Error> {
864 let (inner, format_version, encrypted) = decode_cache(path, config.cache_encryption())?;
865 Ok(Self {
866 inner,
867 available: true,
868 encrypted,
869 format_version: Some(format_version),
870 path: Some(path.to_path_buf()),
871 })
872 }
873
874 pub fn load_with_key(path: &Path, config: &Config, key: &CacheKey) -> Result<Self, Error> {
884 require_cache_encryption(config)?;
885 let (inner, format_version, encrypted) = decode_cache_with_key(path, key)?;
886 Ok(Self {
887 inner,
888 available: true,
889 encrypted,
890 format_version: Some(format_version),
891 path: Some(path.to_path_buf()),
892 })
893 }
894
895 pub fn load_optional(path: &Path, config: &Config) -> Result<Self, Error> {
905 match std::fs::metadata(path) {
906 Ok(_) => Self::load(path, config),
907 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self::unavailable()),
908 Err(error) => Err(Error::with_source(
909 ErrorKind::Cache,
910 format!("failed to inspect {}", path.display()),
911 error,
912 )),
913 }
914 }
915
916 pub fn load_optional_with_key(
926 path: &Path,
927 config: &Config,
928 key: &CacheKey,
929 ) -> Result<Self, Error> {
930 require_cache_encryption(config)?;
931 match std::fs::metadata(path) {
932 Ok(_) => Self::load_with_key(path, config, key),
933 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self::unavailable()),
934 Err(error) => Err(Error::with_source(
935 ErrorKind::Cache,
936 format!("failed to inspect {}", path.display()),
937 error,
938 )),
939 }
940 }
941
942 pub fn is_available(&self) -> bool {
944 self.available
945 }
946
947 pub fn is_stale(&self, stale_days: u64) -> bool {
949 self.available
950 && self
951 .inner
952 .metadata
953 .created_at_unix_secs
954 .is_none_or(|created_at| {
955 now_unix_seconds()
956 .checked_sub(created_at)
957 .is_none_or(|age| age > stale_days.saturating_mul(24 * 60 * 60))
958 })
959 }
960
961 pub fn inspect(&self) -> BaselineInspection {
963 BaselineInspection::from_baseline(self)
964 }
965
966 fn report(&self, stale_days: u64) -> BaselineReport {
967 BaselineReport {
968 status: if !self.available {
969 BaselineStatus::Unavailable
970 } else if self.is_stale(stale_days) {
971 BaselineStatus::Stale
972 } else {
973 BaselineStatus::Available
974 },
975 created_at_unix_secs: self.inner.metadata.created_at_unix_secs,
976 source_database: self.inner.metadata.source_database.clone(),
977 schemas: self.inner.metadata.schemas.clone(),
978 auto_sync: AutoSyncStatus::NotRequested,
979 observed_settings: ObservedSettings {
980 lock_timeout_ms: self
981 .available
982 .then_some(self.inner.metadata.source_lock_timeout_ms),
983 statement_timeout_ms: self
984 .available
985 .then_some(self.inner.metadata.source_statement_timeout_ms),
986 },
987 }
988 }
989}
990
991fn require_cache_encryption(config: &Config) -> Result<(), Error> {
992 if config.cache_encryption() {
993 Ok(())
994 } else {
995 Err(Error::configuration(
996 "cache_encryption must be enabled when an explicit cache key is supplied",
997 ))
998 }
999}
1000
1001#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1003#[non_exhaustive]
1004pub struct ObservedSettings {
1005 pub lock_timeout_ms: Option<u64>,
1007 pub statement_timeout_ms: Option<u64>,
1009}
1010
1011#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1013#[serde(rename_all = "snake_case")]
1014#[non_exhaustive]
1015pub enum BaselineStatus {
1016 Available,
1018 Stale,
1020 Unavailable,
1022}
1023
1024impl BaselineStatus {
1025 fn label(self) -> &'static str {
1026 match self {
1027 Self::Available => "available",
1028 Self::Stale => "stale",
1029 Self::Unavailable => "unavailable",
1030 }
1031 }
1032}
1033
1034#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1036#[serde(rename_all = "snake_case")]
1037#[non_exhaustive]
1038pub enum AutoSyncStatus {
1039 NotRequested,
1041 Refreshed,
1043 Failed,
1045 Bypassed,
1047}
1048
1049impl AutoSyncStatus {
1050 fn label(self) -> &'static str {
1051 match self {
1052 Self::NotRequested => "not_requested",
1053 Self::Refreshed => "refreshed",
1054 Self::Failed => "failed",
1055 Self::Bypassed => "bypassed",
1056 }
1057 }
1058}
1059
1060#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1062#[non_exhaustive]
1063pub struct BaselineReport {
1064 pub status: BaselineStatus,
1066 pub created_at_unix_secs: Option<u64>,
1068 pub source_database: Option<String>,
1070 pub schemas: Option<Vec<String>>,
1072 pub auto_sync: AutoSyncStatus,
1074 pub observed_settings: ObservedSettings,
1076}
1077
1078#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1080#[non_exhaustive]
1081pub struct BaselineContents {
1082 pub schemas: usize,
1084 pub sequences: usize,
1086 pub relations: usize,
1088 pub tables: usize,
1090 pub views: usize,
1092 pub materialized_views: usize,
1094 pub columns: usize,
1096 pub indexes: usize,
1098 pub foreign_keys: usize,
1100 pub constraints: usize,
1102 pub constraint_keys: usize,
1104 pub triggers: usize,
1106 pub functions: usize,
1108 pub procedures: usize,
1110 pub aggregates: usize,
1112 pub window_functions: usize,
1114 pub publications: usize,
1116 pub subscriptions: usize,
1118 pub types: usize,
1120 pub roles: usize,
1122 pub dependencies: usize,
1124 pub inheritances: usize,
1126}
1127
1128#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1130#[non_exhaustive]
1131pub struct BaselineInspection {
1132 pub available: bool,
1134 pub path: Option<String>,
1136 pub format_version: Option<u32>,
1138 pub encrypted: bool,
1140 pub created_at_unix_secs: Option<u64>,
1142 pub age_seconds: Option<u64>,
1145 pub source_database: Option<String>,
1147 pub schemas: Option<Vec<String>>,
1149 pub coverage: BaselineCoverage,
1151 pub search_path: Vec<String>,
1153 pub postgresql_version_num: Option<u32>,
1155 pub observed_settings: ObservedSettings,
1157 pub contents: BaselineContents,
1159}
1160
1161#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1163#[non_exhaustive]
1164pub struct BaselineCoverage {
1165 pub schema_scope: BaselineSchemaScope,
1167 pub families: Vec<String>,
1169}
1170
1171#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1173#[serde(rename_all = "snake_case")]
1174#[non_exhaustive]
1175pub enum BaselineSchemaScope {
1176 AllNonSystem,
1178 Explicit,
1180}
1181
1182impl BaselineInspection {
1183 fn from_baseline(baseline: &Baseline) -> Self {
1184 let cache = &baseline.inner;
1185 let mut tables = 0;
1186 let mut views = 0;
1187 let mut materialized_views = 0;
1188 let mut columns = 0;
1189 for relation in cache.relations.values() {
1190 columns += relation.columns.len();
1191 match relation.kind {
1192 RelationKind::Table => tables += 1,
1193 RelationKind::View => views += 1,
1194 RelationKind::MaterializedView => materialized_views += 1,
1195 }
1196 }
1197 let mut functions = 0;
1198 let mut procedures = 0;
1199 let mut aggregates = 0;
1200 let mut window_functions = 0;
1201 for routine in cache.functions.values() {
1202 match routine.routine_kind {
1203 RoutineKind::Function => functions += 1,
1204 RoutineKind::Procedure => procedures += 1,
1205 RoutineKind::Aggregate => aggregates += 1,
1206 RoutineKind::Window => window_functions += 1,
1207 }
1208 }
1209 Self {
1210 available: baseline.available,
1211 path: baseline
1212 .path
1213 .as_ref()
1214 .map(|path| path.display().to_string()),
1215 format_version: baseline.format_version,
1216 encrypted: baseline.encrypted,
1217 created_at_unix_secs: cache.metadata.created_at_unix_secs,
1218 age_seconds: cache
1219 .metadata
1220 .created_at_unix_secs
1221 .and_then(|created_at| now_unix_seconds().checked_sub(created_at)),
1222 source_database: cache.metadata.source_database.clone(),
1223 schemas: cache.metadata.schemas.clone(),
1224 coverage: BaselineCoverage {
1225 schema_scope: match cache.coverage.schema_scope {
1226 crate::_internal::db::cache::SchemaCoverage::AllNonSystem => {
1227 BaselineSchemaScope::AllNonSystem
1228 }
1229 crate::_internal::db::cache::SchemaCoverage::Explicit(_) => {
1230 BaselineSchemaScope::Explicit
1231 }
1232 },
1233 families: cache.coverage.family_names().map(str::to_owned).collect(),
1234 },
1235 search_path: cache.search_path.clone(),
1236 postgresql_version_num: cache.pg_version_num,
1237 observed_settings: ObservedSettings {
1238 lock_timeout_ms: baseline
1239 .available
1240 .then_some(cache.metadata.source_lock_timeout_ms),
1241 statement_timeout_ms: baseline
1242 .available
1243 .then_some(cache.metadata.source_statement_timeout_ms),
1244 },
1245 contents: BaselineContents {
1246 schemas: cache.schemas.len(),
1247 sequences: cache.sequences.len(),
1248 relations: cache.relations.len(),
1249 tables,
1250 views,
1251 materialized_views,
1252 columns,
1253 indexes: cache.indexes.len(),
1254 foreign_keys: cache.foreign_keys.len(),
1255 constraints: cache.constraints.len(),
1256 constraint_keys: cache.constraint_keys.len(),
1257 triggers: cache.triggers.len(),
1258 functions,
1259 procedures,
1260 aggregates,
1261 window_functions,
1262 publications: cache.publications.len(),
1263 subscriptions: cache.subscriptions.len(),
1264 types: cache.types.len(),
1265 roles: cache.roles.len(),
1266 dependencies: cache.dependencies.len(),
1267 inheritances: cache.inheritances.len(),
1268 },
1269 }
1270 }
1271}
1272
1273#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1275#[non_exhaustive]
1276pub struct Rule {
1277 pub id: String,
1279 pub title: String,
1281 pub summary: String,
1283 pub impact: String,
1285 pub default_tier: Tier,
1287 pub remediation: String,
1289 pub supported_configuration_fields: Vec<RuleConfigurationField>,
1291 pub enabled: bool,
1293 pub tier1_threshold_rows: Option<u64>,
1295 pub tier2_threshold_rows: Option<u64>,
1297}
1298
1299#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
1301#[serde(rename_all = "snake_case")]
1302#[non_exhaustive]
1303pub enum RuleConfigurationField {
1304 Disabled,
1306 Tier1ThresholdRows,
1308 Tier2ThresholdRows,
1310}
1311
1312impl RuleConfigurationField {
1313 pub fn as_str(self) -> &'static str {
1315 match self {
1316 Self::Disabled => "disabled",
1317 Self::Tier1ThresholdRows => "tier1_threshold_rows",
1318 Self::Tier2ThresholdRows => "tier2_threshold_rows",
1319 }
1320 }
1321}
1322
1323pub fn validate_config(config: &Config) -> Result<(), Error> {
1330 if config.default_rows() == 0 {
1331 return Err(Error::configuration(
1332 "default_rows must be greater than zero",
1333 ));
1334 }
1335 if config.toast_width_threshold_bytes() <= 0 {
1336 return Err(Error::configuration(
1337 "toast_width_threshold_bytes must be greater than zero",
1338 ));
1339 }
1340 let assumed_version = config.assumed_postgres_version();
1341 if assumed_version != 100_000 && !(140_000..=180_999).contains(&assumed_version) {
1342 return Err(Error::configuration(
1343 "assume_pg_version must be 100000 (the conservative no-baseline default) or a PostgreSQL 14–18 version number",
1344 ));
1345 }
1346 config
1347 .validate_rule_ids(registry::primary_rule_ids())
1348 .and_then(|_| config.sync_schemas(None).map(|_| ()))?;
1349 registry::validate_rule_configuration(config).map_err(Error::configuration)
1350}
1351
1352pub fn rules(config: &Config) -> Result<Vec<Rule>, Error> {
1358 validate_config(config)?;
1359 Ok(registry::PRIMARY_RULES
1360 .iter()
1361 .map(|descriptor| Rule {
1362 id: descriptor.id.to_owned(),
1363 title: descriptor.title.to_owned(),
1364 summary: descriptor.summary.to_owned(),
1365 impact: descriptor.impact.to_owned(),
1366 default_tier: descriptor.default_tier().into(),
1367 remediation: descriptor.recipe().to_owned(),
1368 supported_configuration_fields: descriptor
1369 .supported_configuration_fields
1370 .iter()
1371 .copied()
1372 .map(RuleConfigurationField::from)
1373 .collect(),
1374 enabled: !config.is_rule_disabled(descriptor.id),
1375 tier1_threshold_rows: descriptor
1376 .supports(InternalRuleConfigurationField::Tier1ThresholdRows)
1377 .then(|| config.rule_tier1_threshold(descriptor.id)),
1378 tier2_threshold_rows: descriptor
1379 .supports(InternalRuleConfigurationField::Tier2ThresholdRows)
1380 .then(|| config.rule_tier2_threshold(descriptor.id)),
1381 })
1382 .collect())
1383}
1384
1385pub fn rule(config: &Config, rule_id: &str) -> Result<Rule, Error> {
1392 rules(config)?
1393 .into_iter()
1394 .find(|rule| rule.id == rule_id)
1395 .ok_or_else(|| Error::new(ErrorKind::UnknownRule, rule_id))
1396}
1397
1398pub fn sync(out: &Path, config: &Config, schemas: Option<&[String]>) -> Result<(), Error> {
1411 validate_config(config)?;
1412 let schemas = config.sync_schemas(schemas)?;
1413 crate::_internal::sync::sync_cache(out, schemas, config.cache_encryption()).map_err(|error| {
1414 let message = error.to_string();
1415 Error::with_anyhow_source(ErrorKind::Sync, message, error)
1416 })
1417}
1418
1419pub fn sync_with_secrets(
1431 out: &Path,
1432 config: &Config,
1433 schemas: Option<&[String]>,
1434 database_url: &DatabaseUrl,
1435 cache_key: Option<&CacheKey>,
1436) -> Result<(), Error> {
1437 validate_config(config)?;
1438 match (config.cache_encryption(), cache_key) {
1439 (true, None) => {
1440 return Err(Error::configuration(
1441 "an explicit cache key is required when cache_encryption is enabled",
1442 ));
1443 }
1444 (false, Some(_)) => {
1445 return Err(Error::configuration(
1446 "an explicit cache key requires cache_encryption to be enabled",
1447 ));
1448 }
1449 _ => {}
1450 }
1451 let schemas = config.sync_schemas(schemas)?;
1452 crate::_internal::sync::sync_cache_with_secrets(
1453 out,
1454 schemas,
1455 database_url.expose(),
1456 cache_key.map(CacheKey::expose),
1457 )
1458 .map_err(|error| {
1459 let message = error.to_string();
1460 Error::with_anyhow_source(ErrorKind::Sync, message, error)
1461 })
1462}
1463
1464pub fn analyze(
1472 config: &Config,
1473 filename: impl Into<String>,
1474 sql: impl Into<String>,
1475 baseline: &Baseline,
1476) -> Result<AnalysisOutcome, Error> {
1477 analyze_chain(config, [Migration::new(filename, sql)], baseline)
1478}
1479
1480pub fn analyze_chain(
1488 config: &Config,
1489 migrations: impl IntoIterator<Item = Migration>,
1490 baseline: &Baseline,
1491) -> Result<AnalysisOutcome, Error> {
1492 validate_config(config)?;
1493 let baseline_unavailable = !baseline.available;
1494 let baseline_stale = baseline.is_stale(config.stale_stats_days());
1495 let files: Vec<(String, String)> = migrations
1496 .into_iter()
1497 .map(|migration| (migration.filename, migration.sql))
1498 .collect();
1499 let mut state =
1500 InternalAnalysisState::try_with_baseline(baseline.inner.clone(), baseline.available)
1501 .map_err(Error::cache)?;
1502 let engine = SafeMigrateEngine::new(config.clone());
1503 let inner = engine
1504 .analyze_chain_outcome_with_locations(&files, &mut state)
1505 .map_err(Error::analysis)?;
1506 let mut outcome =
1507 AnalysisOutcome::from_internal(inner, baseline.report(config.stale_stats_days()));
1508 if baseline_unavailable {
1509 outcome = outcome.with_evidence(EvidenceCode::BaselineUnavailable, EvidenceScope::Chain);
1510 } else if baseline_stale {
1511 outcome = outcome.with_evidence(EvidenceCode::BaselineStale, EvidenceScope::Chain);
1512 }
1513 Ok(outcome)
1514}
1515
1516fn decode_cache(
1517 path: &Path,
1518 cache_encryption: bool,
1519) -> Result<(InternalDbCache, u32, bool), Error> {
1520 let encoded = read_cache_bytes(path).map_err(|error| {
1521 let detail = error.to_string();
1522 Error::with_anyhow_source(
1523 ErrorKind::Cache,
1524 format!("failed to read {}: {detail}", path.display()),
1525 error,
1526 )
1527 })?;
1528 let encrypted = is_encrypted_cache_bytes(&encoded);
1529 let decrypted = unprotect_cache_bytes(encoded, cache_encryption).map_err(|error| {
1530 let detail = error.to_string();
1531 Error::with_anyhow_source(
1532 ErrorKind::Cache,
1533 format!("failed to unlock {}: {detail}", path.display()),
1534 error,
1535 )
1536 })?;
1537 decode_cache_payload(path, decrypted, encrypted)
1538}
1539
1540fn decode_cache_payload(
1541 path: &Path,
1542 decrypted: Vec<u8>,
1543 encrypted: bool,
1544) -> Result<(InternalDbCache, u32, bool), Error> {
1545 let decrypted = Zeroizing::new(decrypted);
1546 let decoder = zstd::stream::Decoder::new(std::io::Cursor::new(decrypted)).map_err(|error| {
1547 Error::with_source(
1548 ErrorKind::Cache,
1549 format!("{}: zstd initialization failed", path.display()),
1550 error,
1551 )
1552 })?;
1553 let mut decoder = decoder.take(MAX_CACHE_DECODE_BYTES as u64 + 1);
1554 let mut header = Vec::with_capacity(CACHE_V8_MAGIC.len());
1555 decoder
1556 .by_ref()
1557 .take(CACHE_V8_MAGIC.len() as u64)
1558 .read_to_end(&mut header)
1559 .map_err(|error| {
1560 Error::with_source(
1561 ErrorKind::Cache,
1562 format!("{} is truncated or corrupted", path.display()),
1563 error,
1564 )
1565 })?;
1566 if header.len() < CACHE_V8_MAGIC.len() && CACHE_V8_MAGIC.starts_with(&header) {
1567 return Err(Error::cache(format!(
1568 "{} is truncated or corrupted",
1569 path.display()
1570 )));
1571 }
1572 if header != CACHE_V8_MAGIC {
1573 return Err(Error::cache(format!(
1574 "{} uses an unsupported cache format; run `safe-migrate sync`",
1575 path.display()
1576 )));
1577 }
1578 let codec = bincode::config::standard()
1579 .with_variable_int_encoding()
1580 .with_limit::<MAX_CACHE_DECODE_BYTES>();
1581 let versioned: DbCacheVersioned = bincode::serde::decode_from_std_read(&mut decoder, codec)
1582 .map_err(|error| {
1583 let detail = if matches!(&error, bincode::error::DecodeError::LimitExceeded) {
1584 format!(
1585 "exceeds the {} MiB decoded-size limit",
1586 MAX_CACHE_DECODE_BYTES / (1024 * 1024)
1587 )
1588 } else {
1589 error.to_string()
1590 };
1591 Error::with_source(
1592 ErrorKind::Cache,
1593 format!("{} is corrupted (bincode): {detail}", path.display()),
1594 error,
1595 )
1596 })?;
1597 let remaining_before_trailing = decoder.limit();
1598 std::io::copy(&mut decoder, &mut std::io::sink()).map_err(|error| {
1599 Error::with_source(
1600 ErrorKind::Cache,
1601 format!("{} is corrupted while decompressing", path.display()),
1602 error,
1603 )
1604 })?;
1605 let decompressed = (MAX_CACHE_DECODE_BYTES as u64 + 1) - decoder.limit();
1606 if decompressed > MAX_CACHE_DECODE_BYTES as u64 {
1607 return Err(Error::cache(format!(
1608 "{} exceeds the {} MiB decoded-size limit",
1609 path.display(),
1610 MAX_CACHE_DECODE_BYTES / (1024 * 1024)
1611 )));
1612 }
1613 if decoder.limit() != remaining_before_trailing {
1614 return Err(Error::cache(format!(
1615 "{} contains trailing payload data",
1616 path.display()
1617 )));
1618 }
1619 let format_version = versioned.format_version();
1620 if format_version != CACHE_FORMAT_VERSION {
1621 return Err(Error::cache(format!(
1622 "{} has a mismatched cache format header",
1623 path.display()
1624 )));
1625 }
1626 let cache = versioned.into_cache().map_err(Error::cache)?;
1627 Ok((cache, format_version, encrypted))
1628}
1629
1630fn decode_cache_with_key(
1631 path: &Path,
1632 key: &CacheKey,
1633) -> Result<(InternalDbCache, u32, bool), Error> {
1634 let encoded = read_cache_bytes(path).map_err(|error| {
1635 let detail = error.to_string();
1636 Error::with_anyhow_source(
1637 ErrorKind::Cache,
1638 format!("failed to read {}: {detail}", path.display()),
1639 error,
1640 )
1641 })?;
1642 let decrypted = unprotect_cache_bytes_with_key(encoded, key.expose()).map_err(|error| {
1643 let detail = error.to_string();
1644 Error::with_anyhow_source(
1645 ErrorKind::Cache,
1646 format!("failed to unlock {}: {detail}", path.display()),
1647 error,
1648 )
1649 })?;
1650 decode_cache_payload(path, decrypted, true)
1651}
1652
1653fn now_unix_seconds() -> u64 {
1654 SystemTime::now()
1655 .duration_since(UNIX_EPOCH)
1656 .unwrap_or_default()
1657 .as_secs()
1658}
1659
1660impl AnalysisOutcome {
1661 fn from_internal(inner: InternalOutcome<InternalFinding>, baseline: BaselineReport) -> Self {
1662 Self {
1663 findings: inner.findings.iter().map(Finding::from).collect(),
1664 confidence: inner.confidence.clone().into(),
1665 evidence: inner.evidence.iter().map(Evidence::from).collect(),
1666 baseline,
1667 inner,
1668 }
1669 }
1670}
1671
1672fn format_timeout(timeout_ms: Option<u64>) -> String {
1673 timeout_ms.map_or_else(|| "unknown".to_owned(), |value| format!("{value} ms"))
1674}
1675
1676fn markdown_inline_code(value: &str) -> String {
1677 let mut output = String::with_capacity(value.len());
1678 for character in value.chars() {
1679 match character {
1680 '`' => output.push('\''),
1681 '\r' | '\n' => output.push(' '),
1682 character if character.is_control() => output.extend(character.escape_default()),
1683 character => output.push(character),
1684 }
1685 }
1686 output
1687}
1688
1689impl From<&InternalFinding> for Finding {
1690 fn from(finding: &InternalFinding) -> Self {
1691 let violation = &finding.violation;
1692 let descriptor = registry::find_primary_rule(violation.rule_id);
1693 Self {
1694 rule_id: violation.rule_id.to_owned(),
1695 operation_kind: (&violation.operation_kind).into(),
1696 object_kind: (&violation.object_kind).into(),
1697 object_name: violation.object_name.clone(),
1698 tier: violation.tier.clone().into(),
1699 reason: violation.reason.clone(),
1700 recipe: violation.recipe.to_owned(),
1701 dedup_key: violation.dedup_key.clone(),
1702 sql: violation.sql.clone(),
1703 foreign_key_dependency_related: violation.fk_dependency_related,
1704 rule_title: descriptor.map(|descriptor| descriptor.title.to_owned()),
1705 rule_summary: descriptor.map(|descriptor| descriptor.summary.to_owned()),
1706 impact: descriptor.map(|descriptor| descriptor.impact.to_owned()),
1707 location: finding.location.as_ref().map(|location| SourceLocation {
1708 file: location.file.clone(),
1709 line: location.line,
1710 column: location.column,
1711 }),
1712 statement_index: finding.statement_index,
1713 }
1714 }
1715}
1716
1717impl From<InternalConfidence> for Confidence {
1718 fn from(value: InternalConfidence) -> Self {
1719 match value {
1720 InternalConfidence::Exact => Self::Exact,
1721 InternalConfidence::Tainted => Self::Tainted,
1722 }
1723 }
1724}
1725
1726impl From<InternalTier> for Tier {
1727 fn from(value: InternalTier) -> Self {
1728 match value {
1729 InternalTier::Tier1 => Self::Tier1,
1730 InternalTier::Tier2 => Self::Tier2,
1731 InternalTier::Tier3 => Self::Tier3,
1732 }
1733 }
1734}
1735
1736impl From<InternalVerdict> for Verdict {
1737 fn from(value: InternalVerdict) -> Self {
1738 match value {
1739 InternalVerdict::Halt => Self::Halt,
1740 InternalVerdict::Cautious => Self::Cautious,
1741 InternalVerdict::SafeWithRisk => Self::SafeWithRisk,
1742 InternalVerdict::Safe => Self::Safe,
1743 }
1744 }
1745}
1746
1747impl From<InternalRuleConfigurationField> for RuleConfigurationField {
1748 fn from(value: InternalRuleConfigurationField) -> Self {
1749 match value {
1750 InternalRuleConfigurationField::Disabled => Self::Disabled,
1751 InternalRuleConfigurationField::Tier1ThresholdRows => Self::Tier1ThresholdRows,
1752 InternalRuleConfigurationField::Tier2ThresholdRows => Self::Tier2ThresholdRows,
1753 }
1754 }
1755}
1756
1757impl From<&InternalOperationKind> for OperationKind {
1758 fn from(value: &InternalOperationKind) -> Self {
1759 match value {
1760 InternalOperationKind::DropColumn => Self::DropColumn,
1761 InternalOperationKind::DropTable => Self::DropTable,
1762 InternalOperationKind::DropIndex => Self::DropIndex,
1763 InternalOperationKind::DropView => Self::DropView,
1764 InternalOperationKind::DropMaterializedView => Self::DropMaterializedView,
1765 InternalOperationKind::DropFunction => Self::DropFunction,
1766 InternalOperationKind::DropProcedure => Self::DropProcedure,
1767 InternalOperationKind::DropSchema => Self::DropSchema,
1768 InternalOperationKind::DropDatabase => Self::DropDatabase,
1769 InternalOperationKind::DropSequence => Self::DropSequence,
1770 InternalOperationKind::DropDomain => Self::DropDomain,
1771 InternalOperationKind::DropType => Self::DropType,
1772 InternalOperationKind::DropPublication => Self::DropPublication,
1773 InternalOperationKind::DropTrigger => Self::DropTrigger,
1774 InternalOperationKind::DropPolicy => Self::DropPolicy,
1775 InternalOperationKind::AddColumn => Self::AddColumn,
1776 InternalOperationKind::AlterColumnType => Self::AlterColumnType,
1777 InternalOperationKind::AddConstraint => Self::AddConstraint,
1778 InternalOperationKind::CreateIndex => Self::CreateIndex,
1779 InternalOperationKind::CreateTable => Self::CreateTable,
1780 InternalOperationKind::CreateView => Self::CreateView,
1781 InternalOperationKind::AlterFunction => Self::AlterFunction,
1782 InternalOperationKind::AlterProcedure => Self::AlterProcedure,
1783 InternalOperationKind::RefreshMaterializedView => Self::RefreshMaterializedView,
1784 InternalOperationKind::AttachPartition => Self::AttachPartition,
1785 InternalOperationKind::DetachPartition => Self::DetachPartition,
1786 InternalOperationKind::VacuumFull => Self::VacuumFull,
1787 InternalOperationKind::LockTable => Self::LockTable,
1788 InternalOperationKind::TruncateTable => Self::TruncateTable,
1789 InternalOperationKind::Grant => Self::Grant,
1790 InternalOperationKind::AlterType => Self::AlterType,
1791 InternalOperationKind::CreatePolicy => Self::CreatePolicy,
1792 InternalOperationKind::DisableTrigger => Self::DisableTrigger,
1793 InternalOperationKind::EnableTrigger => Self::EnableTrigger,
1794 InternalOperationKind::Rename => Self::Rename,
1795 InternalOperationKind::OpaqueSql => Self::OpaqueSql,
1796 InternalOperationKind::CreateSchema => Self::CreateSchema,
1797 InternalOperationKind::SetDefault => Self::SetDefault,
1798 InternalOperationKind::CreateSequence => Self::CreateSequence,
1799 InternalOperationKind::Conflict => Self::Conflict,
1800 InternalOperationKind::Irreversible => Self::Irreversible,
1801 InternalOperationKind::UnresolvedReference => Self::UnresolvedReference,
1802 InternalOperationKind::Other(name) => Self::Other(name.clone()),
1803 }
1804 }
1805}
1806
1807impl From<&InternalObjectKind> for ObjectKind {
1808 fn from(value: &InternalObjectKind) -> Self {
1809 match value {
1810 InternalObjectKind::Table => Self::Table,
1811 InternalObjectKind::Index => Self::Index,
1812 InternalObjectKind::View => Self::View,
1813 InternalObjectKind::MaterializedView => Self::MaterializedView,
1814 InternalObjectKind::Function => Self::Function,
1815 InternalObjectKind::Procedure => Self::Procedure,
1816 InternalObjectKind::Trigger => Self::Trigger,
1817 InternalObjectKind::Sequence => Self::Sequence,
1818 InternalObjectKind::Schema => Self::Schema,
1819 InternalObjectKind::Role => Self::Role,
1820 InternalObjectKind::Publication => Self::Publication,
1821 InternalObjectKind::Database => Self::Database,
1822 InternalObjectKind::Domain => Self::Domain,
1823 InternalObjectKind::Policy => Self::Policy,
1824 InternalObjectKind::Type => Self::Type,
1825 InternalObjectKind::Opaque => Self::Opaque,
1826 InternalObjectKind::Unknown => Self::Unknown,
1827 }
1828 }
1829}
1830
1831impl From<&internal_evidence::EvidenceRecord> for Evidence {
1832 fn from(record: &internal_evidence::EvidenceRecord) -> Self {
1833 Self {
1834 code: record.code.into(),
1835 scope: record.scope.into(),
1836 summary: record.summary.to_owned(),
1837 location: record.location.as_ref().map(|location| EvidenceLocation {
1838 file: location.file.clone(),
1839 statement_index: location.statement_index,
1840 }),
1841 }
1842 }
1843}
1844
1845impl From<internal_evidence::EvidenceCode> for EvidenceCode {
1846 fn from(value: internal_evidence::EvidenceCode) -> Self {
1847 match value {
1848 internal_evidence::EvidenceCode::BaselineUnavailable => Self::BaselineUnavailable,
1849 internal_evidence::EvidenceCode::BaselineStale => Self::BaselineStale,
1850 internal_evidence::EvidenceCode::CatalogCoverageIncomplete => {
1851 Self::CatalogCoverageIncomplete
1852 }
1853 internal_evidence::EvidenceCode::UnsupportedStatement => Self::UnsupportedStatement,
1854 internal_evidence::EvidenceCode::UnsupportedSemantics => Self::UnsupportedSemantics,
1855 internal_evidence::EvidenceCode::UnresolvedReference => Self::UnresolvedReference,
1856 internal_evidence::EvidenceCode::UnknownObjectState => Self::UnknownObjectState,
1857 internal_evidence::EvidenceCode::TransactionStateUnknown => {
1858 Self::TransactionStateUnknown
1859 }
1860 internal_evidence::EvidenceCode::UnmodeledState => Self::UnmodeledState,
1861 }
1862 }
1863}
1864
1865impl From<EvidenceCode> for internal_evidence::EvidenceCode {
1866 fn from(value: EvidenceCode) -> Self {
1867 match value {
1868 EvidenceCode::BaselineUnavailable => Self::BaselineUnavailable,
1869 EvidenceCode::BaselineStale => Self::BaselineStale,
1870 EvidenceCode::CatalogCoverageIncomplete => Self::CatalogCoverageIncomplete,
1871 EvidenceCode::UnsupportedStatement => Self::UnsupportedStatement,
1872 EvidenceCode::UnsupportedSemantics => Self::UnsupportedSemantics,
1873 EvidenceCode::UnresolvedReference => Self::UnresolvedReference,
1874 EvidenceCode::UnknownObjectState => Self::UnknownObjectState,
1875 EvidenceCode::TransactionStateUnknown => Self::TransactionStateUnknown,
1876 EvidenceCode::UnmodeledState => Self::UnmodeledState,
1877 }
1878 }
1879}
1880
1881impl From<internal_evidence::EvidenceScope> for EvidenceScope {
1882 fn from(value: internal_evidence::EvidenceScope) -> Self {
1883 match value {
1884 internal_evidence::EvidenceScope::Statement => Self::Statement,
1885 internal_evidence::EvidenceScope::Chain => Self::Chain,
1886 }
1887 }
1888}
1889
1890impl From<EvidenceScope> for internal_evidence::EvidenceScope {
1891 fn from(value: EvidenceScope) -> Self {
1892 match value {
1893 EvidenceScope::Statement => Self::Statement,
1894 EvidenceScope::Chain => Self::Chain,
1895 }
1896 }
1897}
1898
1899#[cfg(test)]
1900mod tests {
1901 use super::*;
1902
1903 #[test]
1904 fn future_dated_baseline_is_not_treated_as_fresh() {
1905 let mut baseline = Baseline::unavailable();
1906 baseline.available = true;
1907 baseline.inner.metadata.created_at_unix_secs = Some(u64::MAX);
1908
1909 assert!(baseline.is_stale(u64::MAX));
1910 assert_eq!(baseline.inspect().age_seconds, None);
1911 }
1912
1913 #[test]
1914 fn markdown_inline_values_render_controls_inertly() {
1915 assert_eq!(
1916 markdown_inline_code("cache\x1b[2J\r\n`"),
1917 "cache\\u{1b}[2J '"
1918 );
1919 }
1920}