use rusqlite::Connection;
use serde::Serialize;
use crate::core::errors::{Result, TgaError};
use super::schema::{ObjectKind, SchemaSnapshot};
use super::text_columns::TextClass;
pub const NO_CONTENT_CLAIM: &str =
"tga's database stores no file content, diffs, patches, hunks, or blobs.";
pub const NOT_A_NO_CODE_CLAIM: &str =
"This is not a claim that the database contains no code. Free-text columns hold text a \
human or an upstream system typed, so a pasted snippet in a commit message, a ticket \
title, or an override note is stored verbatim. Those columns are named below and scanned \
in this database, not assumed clean from the schema.";
const CONTENT_NAME_TOKENS: &[&str] = &[
"blob",
"diff",
"patch",
"hunk",
"content",
"payload",
"snippet",
"file_text",
"source_code",
];
#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct DiffConsumer {
pub source_path: &'static str,
pub disposition: &'static str,
}
pub const DIFF_TEXT_CONSUMERS: &[DiffConsumer] = &[DiffConsumer {
source_path: "src/profile/diff_sampler/sampler.rs",
disposition: "holds the diff in memory for the profile period-review prompt \
(`profile::batch_reviewer`) and never binds it to a SQL statement",
}];
pub const DIFF_API_SITES: &[&str] = &["src/collect/git/diff.rs", "src/collect/git/mod.rs"];
#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct ContentColumnFinding {
pub table: String,
pub column: String,
pub declared_type: String,
pub reason: String,
}
#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct ColumnContentScan {
pub table: String,
pub column: String,
pub class: TextClass,
pub rows: i64,
pub populated: i64,
pub max_len: i64,
pub diff_shaped_rows: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Verdict {
Consistent,
Findings,
}
#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct Attestation {
pub claim: &'static str,
pub caveat: &'static str,
pub schema_version: Option<i64>,
pub tables_scanned: usize,
pub content_columns: Vec<ContentColumnFinding>,
pub scanned_columns: Vec<ColumnContentScan>,
pub diff_text_consumers: &'static [DiffConsumer],
pub verdict: Verdict,
}
pub fn attest(conn: &Connection, snapshot: &SchemaSnapshot) -> Result<Attestation> {
let mut content_columns = Vec::new();
let mut scanned_columns = Vec::new();
let mut tables_scanned = 0_usize;
for table in snapshot
.objects
.iter()
.filter(|o| o.kind == ObjectKind::Table)
{
tables_scanned += 1;
for column in &table.columns {
if let Some(reason) = content_reason(&column.name, &column.declared_type) {
content_columns.push(ContentColumnFinding {
table: table.name.clone(),
column: column.name.clone(),
declared_type: column.declared_type.clone(),
reason,
});
}
let Some(class) = column.text_class else {
continue;
};
if !class.is_scanned() {
continue;
}
scanned_columns.push(scan_column(
conn,
&table.name,
&column.name,
class,
table.row_count.unwrap_or(0),
)?);
}
}
let verdict =
if content_columns.is_empty() && scanned_columns.iter().all(|s| s.diff_shaped_rows == 0) {
Verdict::Consistent
} else {
Verdict::Findings
};
Ok(Attestation {
claim: NO_CONTENT_CLAIM,
caveat: NOT_A_NO_CODE_CLAIM,
schema_version: snapshot.schema_version,
tables_scanned,
content_columns,
scanned_columns,
diff_text_consumers: DIFF_TEXT_CONSUMERS,
verdict,
})
}
fn content_reason(name: &str, declared_type: &str) -> Option<String> {
if declared_type.eq_ignore_ascii_case("BLOB") {
return Some("declared BLOB".to_string());
}
let lower = name.to_ascii_lowercase();
CONTENT_NAME_TOKENS
.iter()
.find(|token| lower.contains(*token))
.map(|token| format!("column name contains \"{token}\""))
}
const DIFF_MARKER_PREDICATES: &[&str] = &[
"LIKE '%diff --git %'",
"LIKE '%' || char(10) || '@@ %'",
"LIKE '@@ %'",
"LIKE '%' || char(10) || '--- a/%'",
"LIKE '%' || char(10) || '+++ b/%'",
];
fn unescaped_newlines(column: &str) -> String {
let crlf = "char(92) || char(114) || char(92) || char(110)";
let lf = "char(92) || char(110)";
format!("REPLACE(REPLACE(\"{column}\", {crlf}, char(10)), {lf}, char(10))")
}
fn scan_column(
conn: &Connection,
table: &str,
column: &str,
class: TextClass,
rows: i64,
) -> Result<ColumnContentScan> {
let t = table.replace('"', "\"\"");
let c = column.replace('"', "\"\"");
let (populated, max_len): (i64, i64) = conn
.query_row(
&format!(
"SELECT COUNT(\"{c}\"), COALESCE(MAX(LENGTH(\"{c}\")), 0) \
FROM \"{t}\" WHERE \"{c}\" IS NOT NULL AND \"{c}\" <> ''"
),
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.map_err(TgaError::from)?;
let scanned = unescaped_newlines(&c);
let predicate = DIFF_MARKER_PREDICATES
.iter()
.map(|p| format!("{scanned} {p}"))
.collect::<Vec<_>>()
.join(" OR ");
let diff_shaped_rows: i64 = conn
.query_row(
&format!("SELECT COUNT(*) FROM \"{t}\" WHERE {predicate}"),
[],
|row| row.get(0),
)
.map_err(TgaError::from)?;
Ok(ColumnContentScan {
table: table.to_string(),
column: column.to_string(),
class,
rows,
populated,
max_len,
diff_shaped_rows,
})
}