pub(crate) mod config;
pub use config::{Config, RuleConfig};
pub const REPORT_SCHEMA_VERSION: u32 = InternalReporter::JSON_SCHEMA_VERSION;
use crate::_internal::analysis::evidence as internal_evidence;
use crate::_internal::analysis::outcome::AnalysisOutcome as InternalOutcome;
use crate::_internal::analysis::state::{
AnalysisState as InternalAnalysisState, Confidence as InternalConfidence,
};
use crate::_internal::db::cache::{
CACHE_FORMAT_VERSION, CACHE_V8_MAGIC, DbCache as InternalDbCache, DbCacheVersioned,
};
use crate::_internal::db::cache_file::{
MAX_CACHE_DECODE_BYTES, decode_hex_key, is_encrypted_cache_bytes, read_cache_bytes,
unprotect_cache_bytes, unprotect_cache_bytes_with_key,
};
use crate::_internal::engine::engine::SafeMigrateEngine;
use crate::_internal::model::function::RoutineKind;
use crate::_internal::model::relation::RelationKind;
use crate::_internal::report::reporter::{
Reporter as InternalReporter, Verdict as InternalVerdict, compute_verdict,
};
use crate::_internal::report::violations::{
ObjectKind as InternalObjectKind, OperationKind as InternalOperationKind,
ReportFinding as InternalFinding, Violation as InternalViolation,
ViolationTier as InternalTier,
};
use crate::_internal::rules::registry::{
self, RuleConfigurationField as InternalRuleConfigurationField,
};
use serde::Serialize;
use std::fmt;
use std::io::Read;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use zeroize::{Zeroize, Zeroizing};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ErrorKind {
Configuration,
Cache,
UnknownRule,
Analysis,
Report,
Sync,
}
#[derive(Debug)]
pub struct Error {
kind: ErrorKind,
message: String,
source: Option<Box<dyn std::error::Error + Send + Sync>>,
}
impl Error {
fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
source: None,
}
}
fn with_source(
kind: ErrorKind,
message: impl Into<String>,
source: impl std::error::Error + Send + Sync + 'static,
) -> Self {
Self {
kind,
message: message.into(),
source: Some(Box::new(source)),
}
}
fn with_anyhow_source(
kind: ErrorKind,
message: impl Into<String>,
source: anyhow::Error,
) -> Self {
Self {
kind,
message: message.into(),
source: Some(source.into_boxed_dyn_error()),
}
}
fn configuration(message: impl Into<String>) -> Self {
Self::new(ErrorKind::Configuration, message)
}
fn cache(message: impl Into<String>) -> Self {
Self::new(ErrorKind::Cache, message)
}
fn analysis(errors: Vec<String>) -> Self {
Self::new(ErrorKind::Analysis, errors.join("; "))
}
pub fn kind(&self) -> ErrorKind {
self.kind
}
pub fn message(&self) -> &str {
&self.message
}
}
impl fmt::Display for Error {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self {
kind: ErrorKind::Configuration,
message,
..
} => write!(formatter, "invalid configuration: {message}"),
Self {
kind: ErrorKind::Cache,
message,
..
} => write!(formatter, "invalid baseline cache: {message}"),
Self {
kind: ErrorKind::UnknownRule,
message,
..
} => write!(formatter, "unknown primary rule: {message}"),
Self {
kind: ErrorKind::Analysis,
message,
..
} => write!(formatter, "analysis failed: {message}"),
Self {
kind: ErrorKind::Report,
message,
..
} => write!(formatter, "report failed: {message}"),
Self {
kind: ErrorKind::Sync,
message,
..
} => write!(formatter, "baseline sync failed: {message}"),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.source
.as_deref()
.map(|source| source as &(dyn std::error::Error + 'static))
}
}
pub struct DatabaseUrl(String);
impl DatabaseUrl {
pub fn new(value: impl Into<String>) -> Result<Self, Error> {
let mut value = value.into();
if let Err(error) = crate::_internal::sync::validate_database_url(&value) {
value.zeroize();
let message = error.to_string();
return Err(Error::with_anyhow_source(
ErrorKind::Configuration,
message,
error,
));
}
Ok(Self(value))
}
fn expose(&self) -> &str {
&self.0
}
}
impl fmt::Debug for DatabaseUrl {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("DatabaseUrl([REDACTED])")
}
}
impl Drop for DatabaseUrl {
fn drop(&mut self) {
self.0.zeroize();
}
}
pub struct CacheKey([u8; 32]);
impl CacheKey {
pub fn from_bytes(value: [u8; 32]) -> Self {
Self(value)
}
pub fn from_hex(value: &str) -> Result<Self, Error> {
decode_hex_key(value.trim())
.map(Self)
.map_err(|error| Error::configuration(error.to_string()))
}
fn expose(&self) -> &[u8; 32] {
&self.0
}
}
impl fmt::Debug for CacheKey {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("CacheKey([REDACTED])")
}
}
impl Drop for CacheKey {
fn drop(&mut self) {
self.0.zeroize();
}
}
impl From<[u8; 32]> for CacheKey {
fn from(value: [u8; 32]) -> Self {
Self::from_bytes(value)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub enum Confidence {
Exact,
Tainted,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[non_exhaustive]
pub enum Tier {
Tier1,
Tier2,
Tier3,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub enum OperationKind {
DropColumn,
DropTable,
DropIndex,
DropView,
DropMaterializedView,
DropFunction,
DropProcedure,
DropSchema,
DropDatabase,
DropSequence,
DropDomain,
DropType,
DropPublication,
DropTrigger,
DropPolicy,
AddColumn,
AlterColumnType,
AddConstraint,
CreateIndex,
CreateTable,
CreateView,
CreateFunction,
CreateProcedure,
AlterFunction,
AlterProcedure,
RefreshMaterializedView,
AttachPartition,
DetachPartition,
VacuumFull,
LockTable,
TruncateTable,
Grant,
RevokeGrant,
AlterType,
CreateTrigger,
CreatePolicy,
DisableTrigger,
EnableTrigger,
RenameTable,
RenameColumn,
Rename,
OpaqueSql,
CreateSchema,
SetDefault,
CreateSequence,
CreateDomain,
AlterSchema,
Conflict,
Irreversible,
UnresolvedReference,
Other(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
#[non_exhaustive]
pub enum ObjectKind {
Table,
Index,
View,
MaterializedView,
Function,
Procedure,
Trigger,
Sequence,
Schema,
Role,
Publication,
Subscription,
Database,
Domain,
Policy,
Type,
Opaque,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
#[non_exhaustive]
pub enum Verdict {
#[serde(rename = "HALT")]
Halt,
#[serde(rename = "CAUTIOUS")]
Cautious,
#[serde(rename = "SAFE WITH RISK")]
SafeWithRisk,
#[serde(rename = "SAFE")]
Safe,
}
impl Verdict {
pub fn as_str(self) -> &'static str {
match self {
Self::Halt => "HALT",
Self::Cautious => "CAUTIOUS",
Self::SafeWithRisk => "SAFE WITH RISK",
Self::Safe => "SAFE",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize)]
#[non_exhaustive]
pub struct FindingSummary {
pub total: usize,
pub tier1: usize,
pub tier2: usize,
pub tier3: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum EvidenceCode {
BaselineUnavailable,
BaselineStale,
CatalogCoverageIncomplete,
UnsupportedStatement,
UnsupportedSemantics,
UnresolvedReference,
UnknownObjectState,
TransactionStateUnknown,
UnmodeledState,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum EvidenceScope {
Statement,
Chain,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[non_exhaustive]
pub struct EvidenceLocation {
pub file: String,
pub statement_index: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[non_exhaustive]
pub struct Evidence {
pub code: EvidenceCode,
pub scope: EvidenceScope,
pub summary: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub location: Option<EvidenceLocation>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct SourceLocation {
pub file: String,
pub line: usize,
pub column: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct Finding {
pub rule_id: String,
pub operation_kind: OperationKind,
pub object_kind: ObjectKind,
pub object_name: String,
pub tier: Tier,
pub reason: String,
pub recipe: String,
pub dedup_key: Option<String>,
pub sql: Option<String>,
#[serde(rename = "fk_dependency_related")]
pub foreign_key_dependency_related: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub rule_title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rule_summary: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub impact: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub location: Option<SourceLocation>,
#[serde(skip_serializing_if = "Option::is_none")]
pub statement_index: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Migration {
filename: String,
sql: String,
}
impl Migration {
pub fn new(filename: impl Into<String>, sql: impl Into<String>) -> Self {
Self {
filename: filename.into(),
sql: sql.into(),
}
}
pub fn filename(&self) -> &str {
&self.filename
}
pub fn sql(&self) -> &str {
&self.sql
}
}
#[derive(Clone)]
pub struct AnalysisOutcome {
findings: Vec<Finding>,
confidence: Confidence,
evidence: Vec<Evidence>,
baseline: BaselineReport,
inner: InternalOutcome<InternalFinding>,
}
impl fmt::Debug for AnalysisOutcome {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("AnalysisOutcome")
.field("findings", &self.findings)
.field("confidence", &self.confidence)
.field("evidence", &self.evidence)
.field("baseline", &self.baseline)
.finish()
}
}
impl AnalysisOutcome {
pub fn findings(&self) -> &[Finding] {
&self.findings
}
pub fn confidence(&self) -> Confidence {
self.confidence
}
pub fn evidence(&self) -> &[Evidence] {
&self.evidence
}
pub fn baseline(&self) -> &BaselineReport {
&self.baseline
}
pub fn should_halt(&self) -> bool {
self.verdict() == Verdict::Halt
}
pub fn verdict(&self) -> Verdict {
compute_verdict(&self.violations()).into()
}
pub fn recommendation(&self) -> &'static str {
compute_verdict(&self.violations()).recommendation(&self.inner.confidence)
}
pub fn summary(&self) -> FindingSummary {
self.findings.iter().fold(
FindingSummary {
total: self.findings.len(),
..FindingSummary::default()
},
|mut summary, finding| {
match finding.tier {
Tier::Tier1 => summary.tier1 += 1,
Tier::Tier2 => summary.tier2 += 1,
Tier::Tier3 => summary.tier3 += 1,
}
summary
},
)
}
pub fn json(&self) -> serde_json::Value {
let mut report = InternalReporter::json_outcome_with_locations(&self.inner);
report["baseline"] = serde_json::to_value(&self.baseline)
.expect("API-owned baseline report is always serializable");
report
}
pub fn markdown(&self) -> String {
let mut report = InternalReporter::markdown_outcome(&self.inner);
report.push_str("\n## Baseline\n\n");
report.push_str(&format!(
"- **Status:** `{}`\n- **Automatic sync:** `{}`\n",
self.baseline.status.label(),
self.baseline.auto_sync.label()
));
if let Some(source_database) = &self.baseline.source_database {
report.push_str(&format!(
"- **Source database:** `{}`\n",
markdown_inline_code(source_database)
));
}
if let Some(schemas) = &self.baseline.schemas {
report.push_str(&format!(
"- **Schemas:** `{}`\n",
markdown_inline_code(&schemas.join(", "))
));
}
report.push_str(&format!(
"- **Observed lock timeout:** `{}`\n- **Observed statement timeout:** `{}`\n",
format_timeout(self.baseline.observed_settings.lock_timeout_ms),
format_timeout(self.baseline.observed_settings.statement_timeout_ms)
));
report
}
pub fn print_human(&self) -> bool {
InternalReporter::print_outcome(&self.inner)
}
pub fn run_interactive(&self) -> Result<(), Error> {
crate::_internal::report::interactive::run_interactive(
&self.violations(),
&self.inner.confidence,
)
.map_err(|error| {
let message = error.to_string();
Error::with_anyhow_source(ErrorKind::Report, message, error)
})
}
pub fn with_evidence(mut self, code: EvidenceCode, scope: EvidenceScope) -> Self {
self.inner = self
.inner
.with_evidence(internal_evidence::EvidenceRecord::new(
code.into(),
scope.into(),
));
self.evidence = self.inner.evidence.iter().map(Evidence::from).collect();
self.confidence = self.inner.confidence.clone().into();
self
}
pub fn with_auto_sync_status(mut self, status: AutoSyncStatus) -> Self {
self.baseline.auto_sync = status;
self
}
fn violations(&self) -> Vec<InternalViolation> {
self.inner
.findings
.iter()
.map(|finding| finding.violation.clone())
.collect()
}
}
#[derive(Clone)]
pub struct Baseline {
inner: InternalDbCache,
available: bool,
encrypted: bool,
format_version: Option<u32>,
path: Option<std::path::PathBuf>,
}
impl fmt::Debug for Baseline {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("Baseline")
.field("inspection", &self.inspect())
.finish()
}
}
impl Default for Baseline {
fn default() -> Self {
Self::unavailable()
}
}
impl Baseline {
pub fn unavailable() -> Self {
Self {
inner: InternalDbCache::new(),
available: false,
encrypted: false,
format_version: None,
path: None,
}
}
pub fn load(path: &Path, config: &Config) -> Result<Self, Error> {
let (inner, format_version, encrypted) = decode_cache(path, config.cache_encryption())?;
Ok(Self {
inner,
available: true,
encrypted,
format_version: Some(format_version),
path: Some(path.to_path_buf()),
})
}
pub fn load_with_key(path: &Path, config: &Config, key: &CacheKey) -> Result<Self, Error> {
require_cache_encryption(config)?;
let (inner, format_version, encrypted) = decode_cache_with_key(path, key)?;
Ok(Self {
inner,
available: true,
encrypted,
format_version: Some(format_version),
path: Some(path.to_path_buf()),
})
}
pub fn load_optional(path: &Path, config: &Config) -> Result<Self, Error> {
match std::fs::metadata(path) {
Ok(_) => Self::load(path, config),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self::unavailable()),
Err(error) => Err(Error::with_source(
ErrorKind::Cache,
format!("failed to inspect {}", path.display()),
error,
)),
}
}
pub fn load_optional_with_key(
path: &Path,
config: &Config,
key: &CacheKey,
) -> Result<Self, Error> {
require_cache_encryption(config)?;
match std::fs::metadata(path) {
Ok(_) => Self::load_with_key(path, config, key),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self::unavailable()),
Err(error) => Err(Error::with_source(
ErrorKind::Cache,
format!("failed to inspect {}", path.display()),
error,
)),
}
}
pub fn is_available(&self) -> bool {
self.available
}
pub fn is_stale(&self, stale_days: u64) -> bool {
self.available
&& self
.inner
.metadata
.created_at_unix_secs
.is_none_or(|created_at| {
now_unix_seconds()
.checked_sub(created_at)
.is_none_or(|age| age > stale_days.saturating_mul(24 * 60 * 60))
})
}
pub fn inspect(&self) -> BaselineInspection {
BaselineInspection::from_baseline(self)
}
fn report(&self, stale_days: u64) -> BaselineReport {
BaselineReport {
status: if !self.available {
BaselineStatus::Unavailable
} else if self.is_stale(stale_days) {
BaselineStatus::Stale
} else {
BaselineStatus::Available
},
created_at_unix_secs: self.inner.metadata.created_at_unix_secs,
source_database: self.inner.metadata.source_database.clone(),
schemas: self.inner.metadata.schemas.clone(),
auto_sync: AutoSyncStatus::NotRequested,
observed_settings: ObservedSettings {
lock_timeout_ms: self
.available
.then_some(self.inner.metadata.source_lock_timeout_ms),
statement_timeout_ms: self
.available
.then_some(self.inner.metadata.source_statement_timeout_ms),
},
}
}
}
fn require_cache_encryption(config: &Config) -> Result<(), Error> {
if config.cache_encryption() {
Ok(())
} else {
Err(Error::configuration(
"cache_encryption must be enabled when an explicit cache key is supplied",
))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct ObservedSettings {
pub lock_timeout_ms: Option<u64>,
pub statement_timeout_ms: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum BaselineStatus {
Available,
Stale,
Unavailable,
}
impl BaselineStatus {
fn label(self) -> &'static str {
match self {
Self::Available => "available",
Self::Stale => "stale",
Self::Unavailable => "unavailable",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum AutoSyncStatus {
NotRequested,
Refreshed,
Failed,
Bypassed,
}
impl AutoSyncStatus {
fn label(self) -> &'static str {
match self {
Self::NotRequested => "not_requested",
Self::Refreshed => "refreshed",
Self::Failed => "failed",
Self::Bypassed => "bypassed",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct BaselineReport {
pub status: BaselineStatus,
pub created_at_unix_secs: Option<u64>,
pub source_database: Option<String>,
pub schemas: Option<Vec<String>>,
pub auto_sync: AutoSyncStatus,
pub observed_settings: ObservedSettings,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct BaselineContents {
pub schemas: usize,
pub sequences: usize,
pub relations: usize,
pub tables: usize,
pub views: usize,
pub materialized_views: usize,
pub columns: usize,
pub indexes: usize,
pub foreign_keys: usize,
pub constraints: usize,
pub constraint_keys: usize,
pub triggers: usize,
pub functions: usize,
pub procedures: usize,
pub aggregates: usize,
pub window_functions: usize,
pub publications: usize,
pub subscriptions: usize,
pub types: usize,
pub roles: usize,
pub dependencies: usize,
pub inheritances: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct BaselineInspection {
pub available: bool,
pub path: Option<String>,
pub format_version: Option<u32>,
pub encrypted: bool,
pub created_at_unix_secs: Option<u64>,
pub age_seconds: Option<u64>,
pub source_database: Option<String>,
pub schemas: Option<Vec<String>>,
pub coverage: BaselineCoverage,
pub search_path: Vec<String>,
pub postgresql_version_num: Option<u32>,
pub observed_settings: ObservedSettings,
pub contents: BaselineContents,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct BaselineCoverage {
pub schema_scope: BaselineSchemaScope,
pub families: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum BaselineSchemaScope {
AllNonSystem,
Explicit,
}
impl BaselineInspection {
fn from_baseline(baseline: &Baseline) -> Self {
let cache = &baseline.inner;
let mut tables = 0;
let mut views = 0;
let mut materialized_views = 0;
let mut columns = 0;
for relation in cache.relations.values() {
columns += relation.columns.len();
match relation.kind {
RelationKind::Table => tables += 1,
RelationKind::View => views += 1,
RelationKind::MaterializedView => materialized_views += 1,
}
}
let mut functions = 0;
let mut procedures = 0;
let mut aggregates = 0;
let mut window_functions = 0;
for routine in cache.functions.values() {
match routine.routine_kind {
RoutineKind::Function => functions += 1,
RoutineKind::Procedure => procedures += 1,
RoutineKind::Aggregate => aggregates += 1,
RoutineKind::Window => window_functions += 1,
}
}
Self {
available: baseline.available,
path: baseline
.path
.as_ref()
.map(|path| path.display().to_string()),
format_version: baseline.format_version,
encrypted: baseline.encrypted,
created_at_unix_secs: cache.metadata.created_at_unix_secs,
age_seconds: cache
.metadata
.created_at_unix_secs
.and_then(|created_at| now_unix_seconds().checked_sub(created_at)),
source_database: cache.metadata.source_database.clone(),
schemas: cache.metadata.schemas.clone(),
coverage: BaselineCoverage {
schema_scope: match cache.coverage.schema_scope {
crate::_internal::db::cache::SchemaCoverage::AllNonSystem => {
BaselineSchemaScope::AllNonSystem
}
crate::_internal::db::cache::SchemaCoverage::Explicit(_) => {
BaselineSchemaScope::Explicit
}
},
families: cache.coverage.family_names().map(str::to_owned).collect(),
},
search_path: cache.search_path.clone(),
postgresql_version_num: cache.pg_version_num,
observed_settings: ObservedSettings {
lock_timeout_ms: baseline
.available
.then_some(cache.metadata.source_lock_timeout_ms),
statement_timeout_ms: baseline
.available
.then_some(cache.metadata.source_statement_timeout_ms),
},
contents: BaselineContents {
schemas: cache.schemas.len(),
sequences: cache.sequences.len(),
relations: cache.relations.len(),
tables,
views,
materialized_views,
columns,
indexes: cache.indexes.len(),
foreign_keys: cache.foreign_keys.len(),
constraints: cache.constraints.len(),
constraint_keys: cache.constraint_keys.len(),
triggers: cache.triggers.len(),
functions,
procedures,
aggregates,
window_functions,
publications: cache.publications.len(),
subscriptions: cache.subscriptions.len(),
types: cache.types.len(),
roles: cache.roles.len(),
dependencies: cache.dependencies.len(),
inheritances: cache.inheritances.len(),
},
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct Rule {
pub id: String,
pub title: String,
pub summary: String,
pub impact: String,
pub default_tier: Tier,
pub remediation: String,
pub supported_configuration_fields: Vec<RuleConfigurationField>,
pub enabled: bool,
pub tier1_threshold_rows: Option<u64>,
pub tier2_threshold_rows: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum RuleConfigurationField {
Disabled,
Tier1ThresholdRows,
Tier2ThresholdRows,
}
impl RuleConfigurationField {
pub fn as_str(self) -> &'static str {
match self {
Self::Disabled => "disabled",
Self::Tier1ThresholdRows => "tier1_threshold_rows",
Self::Tier2ThresholdRows => "tier2_threshold_rows",
}
}
}
pub fn validate_config(config: &Config) -> Result<(), Error> {
if config.default_rows() == 0 {
return Err(Error::configuration(
"default_rows must be greater than zero",
));
}
if config.toast_width_threshold_bytes() <= 0 {
return Err(Error::configuration(
"toast_width_threshold_bytes must be greater than zero",
));
}
let assumed_version = config.assumed_postgres_version();
if assumed_version != 100_000 && !(140_000..=180_999).contains(&assumed_version) {
return Err(Error::configuration(
"assume_pg_version must be 100000 (the conservative no-baseline default) or a PostgreSQL 14–18 version number",
));
}
config
.validate_rule_ids(registry::primary_rule_ids())
.and_then(|_| config.sync_schemas(None).map(|_| ()))?;
registry::validate_rule_configuration(config).map_err(Error::configuration)
}
pub fn rules(config: &Config) -> Result<Vec<Rule>, Error> {
validate_config(config)?;
Ok(registry::PRIMARY_RULES
.iter()
.map(|descriptor| Rule {
id: descriptor.id.to_owned(),
title: descriptor.title.to_owned(),
summary: descriptor.summary.to_owned(),
impact: descriptor.impact.to_owned(),
default_tier: descriptor.default_tier().into(),
remediation: descriptor.recipe().to_owned(),
supported_configuration_fields: descriptor
.supported_configuration_fields
.iter()
.copied()
.map(RuleConfigurationField::from)
.collect(),
enabled: !config.is_rule_disabled(descriptor.id),
tier1_threshold_rows: descriptor
.supports(InternalRuleConfigurationField::Tier1ThresholdRows)
.then(|| config.rule_tier1_threshold(descriptor.id)),
tier2_threshold_rows: descriptor
.supports(InternalRuleConfigurationField::Tier2ThresholdRows)
.then(|| config.rule_tier2_threshold(descriptor.id)),
})
.collect())
}
pub fn rule(config: &Config, rule_id: &str) -> Result<Rule, Error> {
rules(config)?
.into_iter()
.find(|rule| rule.id == rule_id)
.ok_or_else(|| Error::new(ErrorKind::UnknownRule, rule_id))
}
pub fn sync(out: &Path, config: &Config, schemas: Option<&[String]>) -> Result<(), Error> {
validate_config(config)?;
let schemas = config.sync_schemas(schemas)?;
crate::_internal::sync::sync_cache(out, schemas, config.cache_encryption()).map_err(|error| {
let message = error.to_string();
Error::with_anyhow_source(ErrorKind::Sync, message, error)
})
}
pub fn sync_with_secrets(
out: &Path,
config: &Config,
schemas: Option<&[String]>,
database_url: &DatabaseUrl,
cache_key: Option<&CacheKey>,
) -> Result<(), Error> {
validate_config(config)?;
match (config.cache_encryption(), cache_key) {
(true, None) => {
return Err(Error::configuration(
"an explicit cache key is required when cache_encryption is enabled",
));
}
(false, Some(_)) => {
return Err(Error::configuration(
"an explicit cache key requires cache_encryption to be enabled",
));
}
_ => {}
}
let schemas = config.sync_schemas(schemas)?;
crate::_internal::sync::sync_cache_with_secrets(
out,
schemas,
database_url.expose(),
cache_key.map(CacheKey::expose),
)
.map_err(|error| {
let message = error.to_string();
Error::with_anyhow_source(ErrorKind::Sync, message, error)
})
}
pub fn analyze(
config: &Config,
filename: impl Into<String>,
sql: impl Into<String>,
baseline: &Baseline,
) -> Result<AnalysisOutcome, Error> {
analyze_chain(config, [Migration::new(filename, sql)], baseline)
}
pub fn analyze_chain(
config: &Config,
migrations: impl IntoIterator<Item = Migration>,
baseline: &Baseline,
) -> Result<AnalysisOutcome, Error> {
validate_config(config)?;
let baseline_unavailable = !baseline.available;
let baseline_stale = baseline.is_stale(config.stale_stats_days());
let files: Vec<(String, String)> = migrations
.into_iter()
.map(|migration| (migration.filename, migration.sql))
.collect();
let mut state =
InternalAnalysisState::try_with_baseline(baseline.inner.clone(), baseline.available)
.map_err(Error::cache)?;
let engine = SafeMigrateEngine::new(config.clone());
let inner = engine
.analyze_chain_outcome_with_locations(&files, &mut state)
.map_err(Error::analysis)?;
let mut outcome =
AnalysisOutcome::from_internal(inner, baseline.report(config.stale_stats_days()));
if baseline_unavailable {
outcome = outcome.with_evidence(EvidenceCode::BaselineUnavailable, EvidenceScope::Chain);
} else if baseline_stale {
outcome = outcome.with_evidence(EvidenceCode::BaselineStale, EvidenceScope::Chain);
}
Ok(outcome)
}
fn decode_cache(
path: &Path,
cache_encryption: bool,
) -> Result<(InternalDbCache, u32, bool), Error> {
let encoded = read_cache_bytes(path).map_err(|error| {
let detail = error.to_string();
Error::with_anyhow_source(
ErrorKind::Cache,
format!("failed to read {}: {detail}", path.display()),
error,
)
})?;
let encrypted = is_encrypted_cache_bytes(&encoded);
let decrypted = unprotect_cache_bytes(encoded, cache_encryption).map_err(|error| {
let detail = error.to_string();
Error::with_anyhow_source(
ErrorKind::Cache,
format!("failed to unlock {}: {detail}", path.display()),
error,
)
})?;
decode_cache_payload(path, decrypted, encrypted)
}
fn decode_cache_payload(
path: &Path,
decrypted: Vec<u8>,
encrypted: bool,
) -> Result<(InternalDbCache, u32, bool), Error> {
let decrypted = Zeroizing::new(decrypted);
let decoder = zstd::stream::Decoder::new(std::io::Cursor::new(decrypted)).map_err(|error| {
Error::with_source(
ErrorKind::Cache,
format!("{}: zstd initialization failed", path.display()),
error,
)
})?;
let mut decoder = decoder.take(MAX_CACHE_DECODE_BYTES as u64 + 1);
let mut header = Vec::with_capacity(CACHE_V8_MAGIC.len());
decoder
.by_ref()
.take(CACHE_V8_MAGIC.len() as u64)
.read_to_end(&mut header)
.map_err(|error| {
Error::with_source(
ErrorKind::Cache,
format!("{} is truncated or corrupted", path.display()),
error,
)
})?;
if header.len() < CACHE_V8_MAGIC.len() && CACHE_V8_MAGIC.starts_with(&header) {
return Err(Error::cache(format!(
"{} is truncated or corrupted",
path.display()
)));
}
if header != CACHE_V8_MAGIC {
return Err(Error::cache(format!(
"{} uses an unsupported cache format; run `safe-migrate sync`",
path.display()
)));
}
let codec = bincode::config::standard()
.with_variable_int_encoding()
.with_limit::<MAX_CACHE_DECODE_BYTES>();
let versioned: DbCacheVersioned = bincode::serde::decode_from_std_read(&mut decoder, codec)
.map_err(|error| {
let detail = if matches!(&error, bincode::error::DecodeError::LimitExceeded) {
format!(
"exceeds the {} MiB decoded-size limit",
MAX_CACHE_DECODE_BYTES / (1024 * 1024)
)
} else {
error.to_string()
};
Error::with_source(
ErrorKind::Cache,
format!("{} is corrupted (bincode): {detail}", path.display()),
error,
)
})?;
let remaining_before_trailing = decoder.limit();
std::io::copy(&mut decoder, &mut std::io::sink()).map_err(|error| {
Error::with_source(
ErrorKind::Cache,
format!("{} is corrupted while decompressing", path.display()),
error,
)
})?;
let decompressed = (MAX_CACHE_DECODE_BYTES as u64 + 1) - decoder.limit();
if decompressed > MAX_CACHE_DECODE_BYTES as u64 {
return Err(Error::cache(format!(
"{} exceeds the {} MiB decoded-size limit",
path.display(),
MAX_CACHE_DECODE_BYTES / (1024 * 1024)
)));
}
if decoder.limit() != remaining_before_trailing {
return Err(Error::cache(format!(
"{} contains trailing payload data",
path.display()
)));
}
let format_version = versioned.format_version();
if format_version != CACHE_FORMAT_VERSION {
return Err(Error::cache(format!(
"{} has a mismatched cache format header",
path.display()
)));
}
let cache = versioned.into_cache().map_err(Error::cache)?;
Ok((cache, format_version, encrypted))
}
fn decode_cache_with_key(
path: &Path,
key: &CacheKey,
) -> Result<(InternalDbCache, u32, bool), Error> {
let encoded = read_cache_bytes(path).map_err(|error| {
let detail = error.to_string();
Error::with_anyhow_source(
ErrorKind::Cache,
format!("failed to read {}: {detail}", path.display()),
error,
)
})?;
let decrypted = unprotect_cache_bytes_with_key(encoded, key.expose()).map_err(|error| {
let detail = error.to_string();
Error::with_anyhow_source(
ErrorKind::Cache,
format!("failed to unlock {}: {detail}", path.display()),
error,
)
})?;
decode_cache_payload(path, decrypted, true)
}
fn now_unix_seconds() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
impl AnalysisOutcome {
fn from_internal(inner: InternalOutcome<InternalFinding>, baseline: BaselineReport) -> Self {
Self {
findings: inner.findings.iter().map(Finding::from).collect(),
confidence: inner.confidence.clone().into(),
evidence: inner.evidence.iter().map(Evidence::from).collect(),
baseline,
inner,
}
}
}
fn format_timeout(timeout_ms: Option<u64>) -> String {
timeout_ms.map_or_else(|| "unknown".to_owned(), |value| format!("{value} ms"))
}
fn markdown_inline_code(value: &str) -> String {
let mut output = String::with_capacity(value.len());
for character in value.chars() {
match character {
'`' => output.push('\''),
'\r' | '\n' => output.push(' '),
character if character.is_control() => output.extend(character.escape_default()),
character => output.push(character),
}
}
output
}
impl From<&InternalFinding> for Finding {
fn from(finding: &InternalFinding) -> Self {
let violation = &finding.violation;
let descriptor = registry::find_primary_rule(violation.rule_id);
Self {
rule_id: violation.rule_id.to_owned(),
operation_kind: (&violation.operation_kind).into(),
object_kind: (&violation.object_kind).into(),
object_name: violation.object_name.clone(),
tier: violation.tier.clone().into(),
reason: violation.reason.clone(),
recipe: violation.recipe.to_owned(),
dedup_key: violation.dedup_key.clone(),
sql: violation.sql.clone(),
foreign_key_dependency_related: violation.fk_dependency_related,
rule_title: descriptor.map(|descriptor| descriptor.title.to_owned()),
rule_summary: descriptor.map(|descriptor| descriptor.summary.to_owned()),
impact: descriptor.map(|descriptor| descriptor.impact.to_owned()),
location: finding.location.as_ref().map(|location| SourceLocation {
file: location.file.clone(),
line: location.line,
column: location.column,
}),
statement_index: finding.statement_index,
}
}
}
impl From<InternalConfidence> for Confidence {
fn from(value: InternalConfidence) -> Self {
match value {
InternalConfidence::Exact => Self::Exact,
InternalConfidence::Tainted => Self::Tainted,
}
}
}
impl From<InternalTier> for Tier {
fn from(value: InternalTier) -> Self {
match value {
InternalTier::Tier1 => Self::Tier1,
InternalTier::Tier2 => Self::Tier2,
InternalTier::Tier3 => Self::Tier3,
}
}
}
impl From<InternalVerdict> for Verdict {
fn from(value: InternalVerdict) -> Self {
match value {
InternalVerdict::Halt => Self::Halt,
InternalVerdict::Cautious => Self::Cautious,
InternalVerdict::SafeWithRisk => Self::SafeWithRisk,
InternalVerdict::Safe => Self::Safe,
}
}
}
impl From<InternalRuleConfigurationField> for RuleConfigurationField {
fn from(value: InternalRuleConfigurationField) -> Self {
match value {
InternalRuleConfigurationField::Disabled => Self::Disabled,
InternalRuleConfigurationField::Tier1ThresholdRows => Self::Tier1ThresholdRows,
InternalRuleConfigurationField::Tier2ThresholdRows => Self::Tier2ThresholdRows,
}
}
}
impl From<&InternalOperationKind> for OperationKind {
fn from(value: &InternalOperationKind) -> Self {
match value {
InternalOperationKind::DropColumn => Self::DropColumn,
InternalOperationKind::DropTable => Self::DropTable,
InternalOperationKind::DropIndex => Self::DropIndex,
InternalOperationKind::DropView => Self::DropView,
InternalOperationKind::DropMaterializedView => Self::DropMaterializedView,
InternalOperationKind::DropFunction => Self::DropFunction,
InternalOperationKind::DropProcedure => Self::DropProcedure,
InternalOperationKind::DropSchema => Self::DropSchema,
InternalOperationKind::DropDatabase => Self::DropDatabase,
InternalOperationKind::DropSequence => Self::DropSequence,
InternalOperationKind::DropDomain => Self::DropDomain,
InternalOperationKind::DropType => Self::DropType,
InternalOperationKind::DropPublication => Self::DropPublication,
InternalOperationKind::DropTrigger => Self::DropTrigger,
InternalOperationKind::DropPolicy => Self::DropPolicy,
InternalOperationKind::AddColumn => Self::AddColumn,
InternalOperationKind::AlterColumnType => Self::AlterColumnType,
InternalOperationKind::AddConstraint => Self::AddConstraint,
InternalOperationKind::CreateIndex => Self::CreateIndex,
InternalOperationKind::CreateTable => Self::CreateTable,
InternalOperationKind::CreateView => Self::CreateView,
InternalOperationKind::AlterFunction => Self::AlterFunction,
InternalOperationKind::AlterProcedure => Self::AlterProcedure,
InternalOperationKind::RefreshMaterializedView => Self::RefreshMaterializedView,
InternalOperationKind::AttachPartition => Self::AttachPartition,
InternalOperationKind::DetachPartition => Self::DetachPartition,
InternalOperationKind::VacuumFull => Self::VacuumFull,
InternalOperationKind::LockTable => Self::LockTable,
InternalOperationKind::TruncateTable => Self::TruncateTable,
InternalOperationKind::Grant => Self::Grant,
InternalOperationKind::AlterType => Self::AlterType,
InternalOperationKind::CreatePolicy => Self::CreatePolicy,
InternalOperationKind::DisableTrigger => Self::DisableTrigger,
InternalOperationKind::EnableTrigger => Self::EnableTrigger,
InternalOperationKind::Rename => Self::Rename,
InternalOperationKind::OpaqueSql => Self::OpaqueSql,
InternalOperationKind::CreateSchema => Self::CreateSchema,
InternalOperationKind::SetDefault => Self::SetDefault,
InternalOperationKind::CreateSequence => Self::CreateSequence,
InternalOperationKind::Conflict => Self::Conflict,
InternalOperationKind::Irreversible => Self::Irreversible,
InternalOperationKind::UnresolvedReference => Self::UnresolvedReference,
InternalOperationKind::Other(name) => Self::Other(name.clone()),
}
}
}
impl From<&InternalObjectKind> for ObjectKind {
fn from(value: &InternalObjectKind) -> Self {
match value {
InternalObjectKind::Table => Self::Table,
InternalObjectKind::Index => Self::Index,
InternalObjectKind::View => Self::View,
InternalObjectKind::MaterializedView => Self::MaterializedView,
InternalObjectKind::Function => Self::Function,
InternalObjectKind::Procedure => Self::Procedure,
InternalObjectKind::Trigger => Self::Trigger,
InternalObjectKind::Sequence => Self::Sequence,
InternalObjectKind::Schema => Self::Schema,
InternalObjectKind::Role => Self::Role,
InternalObjectKind::Publication => Self::Publication,
InternalObjectKind::Database => Self::Database,
InternalObjectKind::Domain => Self::Domain,
InternalObjectKind::Policy => Self::Policy,
InternalObjectKind::Type => Self::Type,
InternalObjectKind::Opaque => Self::Opaque,
InternalObjectKind::Unknown => Self::Unknown,
}
}
}
impl From<&internal_evidence::EvidenceRecord> for Evidence {
fn from(record: &internal_evidence::EvidenceRecord) -> Self {
Self {
code: record.code.into(),
scope: record.scope.into(),
summary: record.summary.to_owned(),
location: record.location.as_ref().map(|location| EvidenceLocation {
file: location.file.clone(),
statement_index: location.statement_index,
}),
}
}
}
impl From<internal_evidence::EvidenceCode> for EvidenceCode {
fn from(value: internal_evidence::EvidenceCode) -> Self {
match value {
internal_evidence::EvidenceCode::BaselineUnavailable => Self::BaselineUnavailable,
internal_evidence::EvidenceCode::BaselineStale => Self::BaselineStale,
internal_evidence::EvidenceCode::CatalogCoverageIncomplete => {
Self::CatalogCoverageIncomplete
}
internal_evidence::EvidenceCode::UnsupportedStatement => Self::UnsupportedStatement,
internal_evidence::EvidenceCode::UnsupportedSemantics => Self::UnsupportedSemantics,
internal_evidence::EvidenceCode::UnresolvedReference => Self::UnresolvedReference,
internal_evidence::EvidenceCode::UnknownObjectState => Self::UnknownObjectState,
internal_evidence::EvidenceCode::TransactionStateUnknown => {
Self::TransactionStateUnknown
}
internal_evidence::EvidenceCode::UnmodeledState => Self::UnmodeledState,
}
}
}
impl From<EvidenceCode> for internal_evidence::EvidenceCode {
fn from(value: EvidenceCode) -> Self {
match value {
EvidenceCode::BaselineUnavailable => Self::BaselineUnavailable,
EvidenceCode::BaselineStale => Self::BaselineStale,
EvidenceCode::CatalogCoverageIncomplete => Self::CatalogCoverageIncomplete,
EvidenceCode::UnsupportedStatement => Self::UnsupportedStatement,
EvidenceCode::UnsupportedSemantics => Self::UnsupportedSemantics,
EvidenceCode::UnresolvedReference => Self::UnresolvedReference,
EvidenceCode::UnknownObjectState => Self::UnknownObjectState,
EvidenceCode::TransactionStateUnknown => Self::TransactionStateUnknown,
EvidenceCode::UnmodeledState => Self::UnmodeledState,
}
}
}
impl From<internal_evidence::EvidenceScope> for EvidenceScope {
fn from(value: internal_evidence::EvidenceScope) -> Self {
match value {
internal_evidence::EvidenceScope::Statement => Self::Statement,
internal_evidence::EvidenceScope::Chain => Self::Chain,
}
}
}
impl From<EvidenceScope> for internal_evidence::EvidenceScope {
fn from(value: EvidenceScope) -> Self {
match value {
EvidenceScope::Statement => Self::Statement,
EvidenceScope::Chain => Self::Chain,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn future_dated_baseline_is_not_treated_as_fresh() {
let mut baseline = Baseline::unavailable();
baseline.available = true;
baseline.inner.metadata.created_at_unix_secs = Some(u64::MAX);
assert!(baseline.is_stale(u64::MAX));
assert_eq!(baseline.inspect().age_seconds, None);
}
#[test]
fn markdown_inline_values_render_controls_inertly() {
assert_eq!(
markdown_inline_code("cache\x1b[2J\r\n`"),
"cache\\u{1b}[2J '"
);
}
}