Skip to main content

safe_migrate/
api.rs

1//! Stable, supported Rust API for safe-migrate.
2//!
3//! The public API deliberately owns its configuration, baseline, and report
4//! types. The analyzer implementation and its mutable schema model remain
5//! crate-private implementation details.
6//!
7//! ```no_run
8//! # fn main() -> Result<(), safe_migrate::api::Error> {
9//! use safe_migrate::api::{self, Baseline, Config};
10//! use std::path::Path;
11//!
12//! let config = Config::load_from_file(Path::new("safe-migrate.toml"))?;
13//! let baseline = Baseline::load_optional(Path::new(".safe-migrate.cache"), &config)?;
14//! let outcome = api::analyze(&config, "001.sql", "CREATE TABLE users (id bigint);", &baseline)?;
15//! if outcome.should_halt() {
16//!     eprintln!("{}", outcome.markdown());
17//! }
18//! # Ok(())
19//! # }
20//! ```
21//!
22//! ```compile_fail
23//! // Internal state-machine types are intentionally not a downstream API.
24//! use safe_migrate::_internal::analysis::state::AnalysisState;
25//! ```
26//!
27//! Analysis outcomes preserve their internal reporting invariants:
28//!
29//! ```compile_fail
30//! # use safe_migrate::api::{self, Baseline, Config};
31//! # fn example() -> Result<(), api::Error> {
32//! let config = Config::default();
33//! let baseline = Baseline::unavailable();
34//! let mut outcome = api::analyze(&config, "001.sql", "", &baseline)?;
35//! outcome.findings.clear();
36//! # Ok(())
37//! # }
38//! ```
39
40pub(crate) mod config;
41
42pub use config::{Config, RuleConfig};
43
44/// Current schema version emitted by [`AnalysisOutcome::json`].
45pub 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/// Broad category of a supported API failure.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
82#[non_exhaustive]
83pub enum ErrorKind {
84    /// Configuration could not be read, parsed, or validated.
85    Configuration,
86    /// A baseline could not be read, authenticated, decoded, or validated.
87    Cache,
88    /// A requested primary rule ID does not exist.
89    UnknownRule,
90    /// One or more SQL sources could not be analyzed.
91    Analysis,
92    /// A report could not be rendered or presented.
93    Report,
94    /// PostgreSQL metadata synchronization failed.
95    Sync,
96}
97
98/// Error returned by the supported API.
99///
100/// The stable [`ErrorKind`] supports programmatic handling while [`Self::message`]
101/// and [`std::error::Error::source`] retain diagnostic detail.
102#[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    /// Return the stable category of this failure.
155    pub fn kind(&self) -> ErrorKind {
156        self.kind
157    }
158
159    /// Return the operation context without its category prefix.
160    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
210/// Validated PostgreSQL connection input for embedded synchronization.
211///
212/// Its debug representation is always redacted. Connections must target
213/// localhost or a Unix socket, matching the CLI security boundary.
214pub struct DatabaseUrl(String);
215
216impl DatabaseUrl {
217    /// Validate and retain a PostgreSQL connection string without connecting.
218    ///
219    /// # Errors
220    ///
221    /// Returns [`ErrorKind::Configuration`] for empty, malformed, or remote
222    /// connection strings.
223    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
254/// Validated 256-bit key for encrypted baseline caches.
255///
256/// Key material is never exposed through formatting or serialization.
257pub struct CacheKey([u8; 32]);
258
259impl CacheKey {
260    /// Retain an already decoded 256-bit cache key.
261    pub fn from_bytes(value: [u8; 32]) -> Self {
262        Self(value)
263    }
264
265    /// Decode a 64-character hexadecimal cache key.
266    ///
267    /// # Errors
268    ///
269    /// Returns [`ErrorKind::Configuration`] when the value is not exactly 32
270    /// bytes of hexadecimal key material.
271    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/// Confidence in the final migration result.
301#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
302#[non_exhaustive]
303pub enum Confidence {
304    /// The result is fully supported by the available modeled evidence.
305    Exact,
306    /// At least one relevant fact was unavailable or could not be modeled exactly.
307    Tainted,
308}
309
310/// Stable severity assigned to a finding.
311#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
312#[non_exhaustive]
313pub enum Tier {
314    /// Blocking safety problem.
315    Tier1,
316    /// Risk requiring explicit review.
317    Tier2,
318    /// Informational or operability guidance.
319    Tier3,
320}
321
322/// Stable category of the SQL operation that produced a finding.
323#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
324#[non_exhaustive]
325pub enum OperationKind {
326    /// Drops a table column.
327    DropColumn,
328    /// Drops a table.
329    DropTable,
330    /// Drops an index.
331    DropIndex,
332    /// Drops a view.
333    DropView,
334    /// Drops a materialized view.
335    DropMaterializedView,
336    /// Drops a function.
337    DropFunction,
338    /// Drops a procedure.
339    DropProcedure,
340    /// Drops a schema.
341    DropSchema,
342    /// Drops a database.
343    DropDatabase,
344    /// Drops a sequence.
345    DropSequence,
346    /// Drops a domain.
347    DropDomain,
348    /// Drops a type.
349    DropType,
350    /// Drops a publication.
351    DropPublication,
352    /// Drops a trigger.
353    DropTrigger,
354    /// Drops a row-level security policy.
355    DropPolicy,
356    /// Adds a table column.
357    AddColumn,
358    /// Changes a column's data type.
359    AlterColumnType,
360    /// Adds a table constraint.
361    AddConstraint,
362    /// Creates an index.
363    CreateIndex,
364    /// Creates a table.
365    CreateTable,
366    /// Creates a view.
367    CreateView,
368    /// Creates a function.
369    CreateFunction,
370    /// Creates a procedure.
371    CreateProcedure,
372    /// Changes a function.
373    AlterFunction,
374    /// Changes a procedure.
375    AlterProcedure,
376    /// Refreshes a materialized view.
377    RefreshMaterializedView,
378    /// Attaches a partition.
379    AttachPartition,
380    /// Detaches a partition.
381    DetachPartition,
382    /// Runs `VACUUM FULL`.
383    VacuumFull,
384    /// Acquires an explicit table lock.
385    LockTable,
386    /// Removes all rows from one or more tables.
387    TruncateTable,
388    /// Grants privileges.
389    Grant,
390    /// Revokes privileges.
391    RevokeGrant,
392    /// Changes a type definition.
393    AlterType,
394    /// Creates a trigger.
395    CreateTrigger,
396    /// Creates a row-level security policy.
397    CreatePolicy,
398    /// Disables a trigger.
399    DisableTrigger,
400    /// Enables a trigger.
401    EnableTrigger,
402    /// Renames a table.
403    RenameTable,
404    /// Renames a table column.
405    RenameColumn,
406    /// Renames an object with no more specific category.
407    Rename,
408    /// SQL whose effects cannot be modeled precisely.
409    OpaqueSql,
410    /// Creates a schema.
411    CreateSchema,
412    /// Sets or drops a column default.
413    SetDefault,
414    /// Creates a sequence.
415    CreateSequence,
416    /// Creates a domain.
417    CreateDomain,
418    /// Changes a schema.
419    AlterSchema,
420    /// Represents a conflict detected before execution.
421    Conflict,
422    /// Represents an irreversible operation.
423    Irreversible,
424    /// Represents a reference that could not be resolved safely.
425    UnresolvedReference,
426    /// A named operation outside the stable categories above.
427    Other(String),
428}
429
430/// Stable category of the database object associated with a finding.
431#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
432#[non_exhaustive]
433pub enum ObjectKind {
434    /// A table.
435    Table,
436    /// An index.
437    Index,
438    /// A view.
439    View,
440    /// A materialized view.
441    MaterializedView,
442    /// A function.
443    Function,
444    /// A procedure.
445    Procedure,
446    /// A trigger.
447    Trigger,
448    /// A sequence.
449    Sequence,
450    /// A schema.
451    Schema,
452    /// A database role.
453    Role,
454    /// A logical replication publication.
455    Publication,
456    /// A logical replication subscription.
457    Subscription,
458    /// A database.
459    Database,
460    /// A domain.
461    Domain,
462    /// A row-level security policy.
463    Policy,
464    /// A PostgreSQL type.
465    Type,
466    /// Object whose identity is opaque to the analyzer.
467    Opaque,
468    /// Unknown object category.
469    Unknown,
470}
471
472/// Overall deployment verdict derived from all findings.
473#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
474#[non_exhaustive]
475pub enum Verdict {
476    /// At least one Tier 1 finding blocks deployment.
477    #[serde(rename = "HALT")]
478    Halt,
479    /// At least one Tier 2 finding requires review and no Tier 1 finding exists.
480    #[serde(rename = "CAUTIOUS")]
481    Cautious,
482    /// Only non-blocking findings exist, including an irreversible operation.
483    #[serde(rename = "SAFE WITH RISK")]
484    SafeWithRisk,
485    /// No modeled blocking or irreversible finding exists.
486    #[serde(rename = "SAFE")]
487    Safe,
488}
489
490impl Verdict {
491    /// Return the stable human and JSON report label.
492    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/// Counts of findings by severity tier.
503#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize)]
504#[non_exhaustive]
505pub struct FindingSummary {
506    /// Total number of findings.
507    pub total: usize,
508    /// Number of blocking Tier 1 findings.
509    pub tier1: usize,
510    /// Number of review-required Tier 2 findings.
511    pub tier2: usize,
512    /// Number of informational Tier 3 findings.
513    pub tier3: usize,
514}
515
516/// Stable reason why analysis had to be conservative.
517#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
518#[serde(rename_all = "snake_case")]
519#[non_exhaustive]
520pub enum EvidenceCode {
521    /// No synchronized database baseline was supplied.
522    BaselineUnavailable,
523    /// The supplied baseline exceeded the configured maximum age.
524    BaselineStale,
525    /// A required catalog family was absent from the baseline.
526    CatalogCoverageIncomplete,
527    /// The parser accepted a statement for which no typed extractor exists.
528    UnsupportedStatement,
529    /// The statement was recognized but some behavior could not be modeled.
530    UnsupportedSemantics,
531    /// An object reference could not be resolved exactly.
532    UnresolvedReference,
533    /// The relevant object state could not be proven.
534    UnknownObjectState,
535    /// Transaction state became uncertain.
536    TransactionStateUnknown,
537    /// A state transition was deliberately treated as opaque.
538    UnmodeledState,
539}
540
541/// Whether evidence affects one statement or the entire migration chain.
542#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
543#[serde(rename_all = "snake_case")]
544#[non_exhaustive]
545pub enum EvidenceScope {
546    /// Evidence applies to one statement.
547    Statement,
548    /// Evidence applies to the complete ordered migration chain.
549    Chain,
550}
551
552/// Location of conservative-analysis evidence.
553#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
554#[non_exhaustive]
555pub struct EvidenceLocation {
556    /// Source filename supplied by the caller.
557    pub file: String,
558    /// One-based statement position within the source file.
559    pub statement_index: usize,
560}
561
562/// Stable explanation for a conservative analysis decision.
563#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
564#[non_exhaustive]
565pub struct Evidence {
566    /// Machine-readable reason code.
567    pub code: EvidenceCode,
568    /// Portion of the analysis affected by this evidence.
569    pub scope: EvidenceScope,
570    /// Human-readable explanation without SQL or credentials.
571    pub summary: String,
572    /// Source location when the evidence belongs to one statement.
573    #[serde(skip_serializing_if = "Option::is_none")]
574    pub location: Option<EvidenceLocation>,
575}
576
577/// One-based source position of a finding.
578#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
579#[non_exhaustive]
580pub struct SourceLocation {
581    /// Source filename supplied by the caller.
582    pub file: String,
583    /// One-based source line.
584    pub line: usize,
585    /// One-based source column.
586    pub column: usize,
587}
588
589/// A source-aware, machine-readable migration finding.
590#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
591#[non_exhaustive]
592pub struct Finding {
593    /// Stable primary rule identifier.
594    pub rule_id: String,
595    /// Stable operation category.
596    pub operation_kind: OperationKind,
597    /// Stable database-object category.
598    pub object_kind: ObjectKind,
599    /// Qualified object name when known.
600    pub object_name: String,
601    /// Effective finding severity.
602    pub tier: Tier,
603    /// Explanation of the detected risk.
604    pub reason: String,
605    /// Recommended remediation.
606    pub recipe: String,
607    /// Optional key used to deduplicate equivalent findings.
608    pub dedup_key: Option<String>,
609    /// SQL statement associated with the finding, when available.
610    pub sql: Option<String>,
611    /// Whether a foreign-key dependency contributed to the finding.
612    #[serde(rename = "fk_dependency_related")]
613    pub foreign_key_dependency_related: bool,
614    /// Current human-readable rule title, when the rule is registered.
615    #[serde(skip_serializing_if = "Option::is_none")]
616    pub rule_title: Option<String>,
617    /// Current short rule description, when the rule is registered.
618    #[serde(skip_serializing_if = "Option::is_none")]
619    pub rule_summary: Option<String>,
620    /// Risk category associated with the rule, when registered.
621    #[serde(skip_serializing_if = "Option::is_none")]
622    pub impact: Option<String>,
623    /// Source line and column, when available.
624    #[serde(skip_serializing_if = "Option::is_none")]
625    pub location: Option<SourceLocation>,
626    /// One-based statement position within the source file.
627    #[serde(skip_serializing_if = "Option::is_none")]
628    pub statement_index: Option<usize>,
629}
630
631/// One named SQL migration in its intended analysis order.
632#[derive(Debug, Clone, PartialEq, Eq)]
633pub struct Migration {
634    filename: String,
635    sql: String,
636}
637
638impl Migration {
639    /// Create one named SQL migration for [`analyze_chain`].
640    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    /// Return the migration's source name, used in finding locations.
648    pub fn filename(&self) -> &str {
649        &self.filename
650    }
651
652    /// Return the SQL source submitted for analysis.
653    pub fn sql(&self) -> &str {
654        &self.sql
655    }
656}
657
658/// Immutable analysis result with API-owned snapshots and built-in renderers.
659#[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    /// Return findings in deterministic report order.
682    pub fn findings(&self) -> &[Finding] {
683        &self.findings
684    }
685
686    /// Return the confidence of the complete analysis.
687    pub fn confidence(&self) -> Confidence {
688        self.confidence
689    }
690
691    /// Return the evidence explaining conservative analysis decisions.
692    pub fn evidence(&self) -> &[Evidence] {
693        &self.evidence
694    }
695
696    /// Return the baseline provenance attached to every report format.
697    pub fn baseline(&self) -> &BaselineReport {
698        &self.baseline
699    }
700
701    /// Return whether any finding is a blocking Tier 1 result.
702    pub fn should_halt(&self) -> bool {
703        self.verdict() == Verdict::Halt
704    }
705
706    /// Return the overall deployment verdict.
707    pub fn verdict(&self) -> Verdict {
708        compute_verdict(&self.violations()).into()
709    }
710
711    /// Return the canonical deployment recommendation for this result.
712    pub fn recommendation(&self) -> &'static str {
713        compute_verdict(&self.violations()).recommendation(&self.inner.confidence)
714    }
715
716    /// Return finding counts by severity tier.
717    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    /// Render the stable JSON report consumed by automation.
735    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    /// Render the Markdown report used in pull-request summaries.
743    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    /// Print the human report and return whether it contains a halt result.
772    pub fn print_human(&self) -> bool {
773        InternalReporter::print_outcome(&self.inner)
774    }
775
776    /// Run the terminal report viewer.
777    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    /// Add explicit conservative evidence before rendering an outcome.
789    ///
790    /// This is useful for callers that know a prerequisite was unavailable
791    /// outside safe-migrate's SQL analysis.
792    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    /// Attach the result of a caller-managed automatic baseline refresh.
805    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/// Opaque, validated database baseline used for analysis.
820#[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    /// Use default worst-case assumptions when no synchronized baseline exists.
846    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    /// Load a synchronized cache, validate its structure, and keep its internal
857    /// representation opaque to callers.
858    ///
859    /// # Errors
860    ///
861    /// Returns [`ErrorKind::Cache`] when the file cannot be read, its encryption
862    /// configuration or key is wrong, or its encoded contents fail validation.
863    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    /// Load an encrypted baseline with key material supplied by the caller.
875    ///
876    /// This entry point avoids process-global environment mutation in embedded
877    /// and concurrent applications.
878    ///
879    /// # Errors
880    ///
881    /// Returns [`ErrorKind::Configuration`] when cache encryption is disabled,
882    /// or [`ErrorKind::Cache`] when the cache cannot be authenticated or decoded.
883    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    /// Load a baseline when it exists, while preserving every other loading
896    /// or validation failure.
897    ///
898    /// A missing path produces [`Baseline::unavailable`]. Every other error is
899    /// returned, so callers cannot silently downgrade a damaged baseline.
900    ///
901    /// # Errors
902    ///
903    /// Returns [`ErrorKind::Cache`] for any failure other than a missing file.
904    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    /// Load an explicitly keyed baseline when it exists.
917    ///
918    /// A missing path produces [`Baseline::unavailable`]. Every other error is
919    /// returned, including authentication and decoding failures.
920    ///
921    /// # Errors
922    ///
923    /// Returns [`ErrorKind::Configuration`] when cache encryption is disabled,
924    /// or [`ErrorKind::Cache`] for any failure other than a missing file.
925    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    /// Return whether this value contains a synchronized baseline.
943    pub fn is_available(&self) -> bool {
944        self.available
945    }
946
947    /// Return whether the baseline is older than the supplied number of days.
948    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    /// Return a redacted, serializable description of baseline contents.
962    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/// Session settings observed while synchronizing a baseline.
1002#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1003#[non_exhaustive]
1004pub struct ObservedSettings {
1005    /// Effective `lock_timeout` in milliseconds.
1006    pub lock_timeout_ms: Option<u64>,
1007    /// Effective `statement_timeout` in milliseconds.
1008    pub statement_timeout_ms: Option<u64>,
1009}
1010
1011/// Availability of the baseline used for an analysis.
1012#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1013#[serde(rename_all = "snake_case")]
1014#[non_exhaustive]
1015pub enum BaselineStatus {
1016    /// A fresh synchronized baseline was used.
1017    Available,
1018    /// A synchronized baseline older than the configured maximum was used.
1019    Stale,
1020    /// Analysis used conservative defaults without a synchronized baseline.
1021    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/// Result of an optional caller-managed baseline refresh.
1035#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1036#[serde(rename_all = "snake_case")]
1037#[non_exhaustive]
1038pub enum AutoSyncStatus {
1039    /// No automatic refresh was requested.
1040    NotRequested,
1041    /// The caller refreshed the baseline before analysis.
1042    Refreshed,
1043    /// A requested refresh failed and analysis continued conservatively.
1044    Failed,
1045    /// The caller explicitly bypassed a configured refresh.
1046    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/// Redacted baseline context included in every machine-readable report.
1061#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1062#[non_exhaustive]
1063pub struct BaselineReport {
1064    /// Availability of the baseline used for analysis.
1065    pub status: BaselineStatus,
1066    /// Baseline creation time as Unix seconds.
1067    pub created_at_unix_secs: Option<u64>,
1068    /// Redacted source database name, when captured.
1069    pub source_database: Option<String>,
1070    /// Explicit synchronized schema scope, when configured.
1071    pub schemas: Option<Vec<String>>,
1072    /// Result of caller-managed automatic synchronization.
1073    pub auto_sync: AutoSyncStatus,
1074    /// Timeouts observed during synchronization.
1075    pub observed_settings: ObservedSettings,
1076}
1077
1078/// Redacted object counts contained in a baseline.
1079#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1080#[non_exhaustive]
1081pub struct BaselineContents {
1082    /// Number of schemas.
1083    pub schemas: usize,
1084    /// Number of sequences.
1085    pub sequences: usize,
1086    /// Number of all relations.
1087    pub relations: usize,
1088    /// Number of tables.
1089    pub tables: usize,
1090    /// Number of views.
1091    pub views: usize,
1092    /// Number of materialized views.
1093    pub materialized_views: usize,
1094    /// Number of relation columns.
1095    pub columns: usize,
1096    /// Number of indexes.
1097    pub indexes: usize,
1098    /// Number of foreign keys.
1099    pub foreign_keys: usize,
1100    /// Number of constraints.
1101    pub constraints: usize,
1102    /// Number of cached constraint-key records.
1103    pub constraint_keys: usize,
1104    /// Number of triggers.
1105    pub triggers: usize,
1106    /// Number of functions.
1107    pub functions: usize,
1108    /// Number of procedures.
1109    pub procedures: usize,
1110    /// Number of aggregates.
1111    pub aggregates: usize,
1112    /// Number of window functions.
1113    pub window_functions: usize,
1114    /// Number of publications.
1115    pub publications: usize,
1116    /// Number of subscriptions.
1117    pub subscriptions: usize,
1118    /// Number of PostgreSQL types.
1119    pub types: usize,
1120    /// Number of roles.
1121    pub roles: usize,
1122    /// Number of dependency edges.
1123    pub dependencies: usize,
1124    /// Number of inheritance edges.
1125    pub inheritances: usize,
1126}
1127
1128/// Redacted baseline inspection suitable for display or serialization.
1129#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1130#[non_exhaustive]
1131pub struct BaselineInspection {
1132    /// Whether a synchronized baseline is present.
1133    pub available: bool,
1134    /// Source cache path, when loaded from disk.
1135    pub path: Option<String>,
1136    /// On-disk cache format version, when a cache is present.
1137    pub format_version: Option<u32>,
1138    /// Whether the source cache was encrypted.
1139    pub encrypted: bool,
1140    /// Baseline creation time as Unix seconds.
1141    pub created_at_unix_secs: Option<u64>,
1142    /// Age of the baseline in seconds, or `None` when its timestamp is absent
1143    /// or lies in the future.
1144    pub age_seconds: Option<u64>,
1145    /// Redacted source database name.
1146    pub source_database: Option<String>,
1147    /// Explicit synchronized schema scope, when configured.
1148    pub schemas: Option<Vec<String>>,
1149    /// Catalog coverage captured by synchronization.
1150    pub coverage: BaselineCoverage,
1151    /// Effective PostgreSQL search path.
1152    pub search_path: Vec<String>,
1153    /// PostgreSQL numeric server version.
1154    pub postgresql_version_num: Option<u32>,
1155    /// Timeouts observed during synchronization.
1156    pub observed_settings: ObservedSettings,
1157    /// Redacted object counts.
1158    pub contents: BaselineContents,
1159}
1160
1161/// Catalog families and schema scope represented by a baseline.
1162#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1163#[non_exhaustive]
1164pub struct BaselineCoverage {
1165    /// Whether all non-system or only explicitly selected schemas were read.
1166    pub schema_scope: BaselineSchemaScope,
1167    /// Stable names of captured catalog families.
1168    pub families: Vec<String>,
1169}
1170
1171/// Schema scope captured when the baseline was synchronized.
1172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1173#[serde(rename_all = "snake_case")]
1174#[non_exhaustive]
1175pub enum BaselineSchemaScope {
1176    /// All visible non-system schemas were synchronized.
1177    AllNonSystem,
1178    /// Synchronization was restricted to an explicit schema list.
1179    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/// Descriptor and effective configuration of one primary rule.
1274#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1275#[non_exhaustive]
1276pub struct Rule {
1277    /// Stable rule identifier.
1278    pub id: String,
1279    /// Human-readable rule title.
1280    pub title: String,
1281    /// Short description of the unsafe pattern.
1282    pub summary: String,
1283    /// Risk category.
1284    pub impact: String,
1285    /// Default severity before confidence adjustment.
1286    pub default_tier: Tier,
1287    /// Recommended remediation.
1288    pub remediation: String,
1289    /// Configuration fields accepted by this rule.
1290    pub supported_configuration_fields: Vec<RuleConfigurationField>,
1291    /// Whether the rule is enabled by the supplied configuration.
1292    pub enabled: bool,
1293    /// Effective Tier 1 row threshold when supported.
1294    pub tier1_threshold_rows: Option<u64>,
1295    /// Effective Tier 2 row threshold when supported.
1296    pub tier2_threshold_rows: Option<u64>,
1297}
1298
1299/// Configuration field supported by an individual rule.
1300#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
1301#[serde(rename_all = "snake_case")]
1302#[non_exhaustive]
1303pub enum RuleConfigurationField {
1304    /// Enable or disable the rule.
1305    Disabled,
1306    /// Override its Tier 1 row threshold.
1307    Tier1ThresholdRows,
1308    /// Override its Tier 2 row threshold.
1309    Tier2ThresholdRows,
1310}
1311
1312impl RuleConfigurationField {
1313    /// Return the `safe-migrate.toml` field name.
1314    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
1323/// Validate configuration against the rule catalog and sync settings.
1324///
1325/// # Errors
1326///
1327/// Returns [`ErrorKind::Configuration`] for unknown rule IDs, unsupported
1328/// per-rule settings, invalid thresholds, or an invalid schema scope.
1329pub 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
1352/// Return every primary rule with its effective configuration.
1353///
1354/// # Errors
1355///
1356/// Returns [`ErrorKind::Configuration`] when `config` is invalid.
1357pub 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
1385/// Look up one primary rule and include its effective configuration.
1386///
1387/// # Errors
1388///
1389/// Returns [`ErrorKind::Configuration`] when `config` is invalid, or
1390/// [`ErrorKind::UnknownRule`] when `rule_id` is not registered.
1391pub 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
1398/// Synchronize PostgreSQL metadata into a cache that can later be loaded as a
1399/// [`Baseline`].
1400///
1401/// The connection is read from `DATABASE_URL` and must target localhost or a
1402/// Unix socket. Encrypted caches read `SAFE_MIGRATE_CACHE_KEY` from the process
1403/// environment.
1404///
1405/// # Errors
1406///
1407/// Returns [`ErrorKind::Configuration`] for invalid settings and
1408/// [`ErrorKind::Sync`] when the connection, catalog read, or durable cache
1409/// replacement fails.
1410pub 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
1419/// Synchronize PostgreSQL metadata using caller-owned secret material.
1420///
1421/// This is the embedded equivalent of [`sync`]. It never reads `DATABASE_URL`
1422/// or `SAFE_MIGRATE_CACHE_KEY` from the process environment. Pass a cache key
1423/// exactly when `cache_encryption` is enabled in `config`.
1424///
1425/// # Errors
1426///
1427/// Returns [`ErrorKind::Configuration`] for invalid settings or an inconsistent
1428/// cache-key choice, and [`ErrorKind::Sync`] for connection, catalog, or cache
1429/// replacement failures.
1430pub 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
1464/// Analyze a single migration against an opaque baseline.
1465///
1466/// # Errors
1467///
1468/// Returns [`ErrorKind::Configuration`], [`ErrorKind::Cache`], or
1469/// [`ErrorKind::Analysis`] when validation, state hydration, or SQL analysis
1470/// fails.
1471pub 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
1480/// Analyze an ordered migration chain against an opaque baseline.
1481///
1482/// # Errors
1483///
1484/// Returns [`ErrorKind::Configuration`], [`ErrorKind::Cache`], or
1485/// [`ErrorKind::Analysis`] when validation, state hydration, or SQL analysis
1486/// fails.
1487pub 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}