use std::borrow::Cow;
use std::fmt::Write as _;
use crate::backend_trait::CodegenBackend;
pub const HEADER_SENTINEL: &str = "scythe:provenance";
pub fn comment_prefix(language: &str) -> &'static str {
match language {
"python" | "ruby" | "elixir" => "#",
_ => "//",
}
}
fn header_suffix(language: &str) -> &'static str {
match language {
"python" => " # noqa: E501",
_ => "",
}
}
fn needs_escape(character: char) -> bool {
character == '\\' || character.is_whitespace() || character.is_control()
}
pub fn sanitize_field(value: &str) -> Cow<'_, str> {
if !value.contains(needs_escape) {
return Cow::Borrowed(value);
}
let mut encoded = String::with_capacity(value.len());
for character in value.chars() {
match character {
'\\' => encoded.push_str(r"\\"),
character if needs_escape(character) => {
write!(encoded, "\\u{{{:x}}}", character as u32).expect("writing to a String cannot fail");
}
character => encoded.push(character),
}
}
Cow::Owned(encoded)
}
fn decode_unicode_escape(after_backslash: &str) -> Option<(char, &str)> {
let body = after_backslash.strip_prefix("u{")?;
let close = body.find('}')?;
let code_point = u32::from_str_radix(&body[..close], 16).ok()?;
Some((char::from_u32(code_point)?, &body[close + 1..]))
}
pub fn decode_field(value: &str) -> Cow<'_, str> {
if !value.contains('\\') {
return Cow::Borrowed(value);
}
let mut decoded = String::with_capacity(value.len());
let mut rest = value;
while let Some(backslash) = rest.find('\\') {
decoded.push_str(&rest[..backslash]);
let after_backslash = &rest[backslash + 1..];
if let Some(tail) = after_backslash.strip_prefix('\\') {
decoded.push('\\');
rest = tail;
} else if let Some((character, tail)) = decode_unicode_escape(after_backslash) {
decoded.push(character);
rest = tail;
} else {
decoded.push('\\');
rest = after_backslash;
}
}
decoded.push_str(rest);
Cow::Owned(decoded)
}
pub fn parse_header_fields(line: &str) -> Option<Vec<(&str, String)>> {
let sentinel_start = line.find(HEADER_SENTINEL)?;
let tail = line[sentinel_start + HEADER_SENTINEL.len()..].trim();
Some(
tail.split_ascii_whitespace()
.filter_map(|token| token.split_once('='))
.map(|(key, value)| (key, decode_field(value).into_owned()))
.collect(),
)
}
pub fn header_line(backend: &dyn CodegenBackend, version: &str, engine: &str, schema: &str, queries: &str) -> String {
build_header_line(backend, version, engine, schema, queries, None)
}
pub fn header_line_with_options(
backend: &dyn CodegenBackend,
version: &str,
engine: &str,
schema: &str,
queries: &str,
options: &str,
) -> String {
build_header_line(backend, version, engine, schema, queries, Some(options))
}
fn build_header_line(
backend: &dyn CodegenBackend,
version: &str,
engine: &str,
schema: &str,
queries: &str,
options: Option<&str>,
) -> String {
let language = &backend.manifest().backend.language;
let comment = comment_prefix(language);
let suffix = header_suffix(language);
let version = sanitize_field(version);
let name = sanitize_field(backend.name());
let engine = sanitize_field(engine);
let schema = sanitize_field(schema);
let queries = sanitize_field(queries);
let options_field = match options {
Some(value) => format!(" options={}", sanitize_field(value)),
None => String::new(),
};
format!(
"{comment} {HEADER_SENTINEL} v={version} backend={name} engine={engine} schema={schema} \
queries={queries}{options_field}{suffix}\n"
)
}
const OPTIONS_FINGERPRINT_ALGORITHM_TAG: &str = "opt1";
fn fnv1a_64(bytes: &[u8]) -> u64 {
const OFFSET_BASIS: u64 = 0xcbf29ce484222325;
const PRIME: u64 = 0x0000_0100_0000_01b3;
let mut hash = OFFSET_BASIS;
for &byte in bytes {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(PRIME);
}
hash
}
pub fn options_fingerprint<'a, I>(options: I, manifest_overlay: Option<&str>) -> String
where
I: IntoIterator<Item = (&'a str, &'a str)>,
{
let mut sorted: Vec<(&str, &str)> = options.into_iter().collect();
sorted.sort_unstable_by_key(|(key, _)| *key);
let mut canonical = String::new();
for (key, value) in sorted {
canonical.push_str("opt\t");
canonical.push_str(&sanitize_field(key));
canonical.push('\t');
canonical.push_str(&sanitize_field(value));
canonical.push('\n');
}
if let Some(overlay) = manifest_overlay {
canonical.push_str("manifest\t");
canonical.push_str(&sanitize_field(overlay));
canonical.push('\n');
}
let hash = fnv1a_64(canonical.as_bytes());
format!("{OPTIONS_FINGERPRINT_ALGORITHM_TAG}:{hash:016x}")
}
pub fn assemble_file(preamble: &str, header: &str, body: &str) -> String {
let separator = if preamble.is_empty() { "" } else { "\n" };
format!("{preamble}{header}{separator}{body}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn comment_prefix_covers_all_ten_manifest_languages() {
let hash_comment = ["python", "ruby", "elixir"];
let slash_comment = ["rust", "typescript", "go", "java", "kotlin", "csharp", "php"];
for language in hash_comment {
assert_eq!(comment_prefix(language), "#", "language: {language}");
}
for language in slash_comment {
assert_eq!(comment_prefix(language), "//", "language: {language}");
}
}
#[test]
fn comment_prefix_defaults_to_slash_slash_for_unknown_language() {
assert_eq!(comment_prefix("cobol"), "//");
}
const RUFF_DEFAULT_LINE_LENGTH: usize = 88;
#[test]
fn header_suffix_is_python_only() {
assert_eq!(header_suffix("python"), " # noqa: E501");
for language in [
"ruby",
"elixir",
"rust",
"typescript",
"go",
"java",
"kotlin",
"csharp",
"php",
"cobol",
] {
assert_eq!(header_suffix(language), "", "language: {language}");
}
}
#[test]
fn python_header_lines_never_trip_ruffs_default_line_length() {
for (backend_name, engine) in [
("python-psycopg3", "postgresql"),
("python-asyncpg", "postgresql"),
("python-aiomysql", "mysql"),
("python-aiosqlite", "sqlite"),
("python-duckdb", "duckdb"),
("python-oracledb", "oracle"),
("python-pyodbc", "mssql"),
("python-snowflake", "snowflake"),
] {
let backend = crate::get_backend(backend_name, engine)
.unwrap_or_else(|e| panic!("{backend_name} with {engine}: {e}"));
let line = header_line(
backend.as_ref(),
"0.13.0",
engine,
"sch1:0123456789abcdef",
"q1:fedcba9876543210",
);
let line = line.strip_suffix('\n').expect("header line ends with a newline");
assert!(
line.len() > RUFF_DEFAULT_LINE_LENGTH,
"test premise: {backend_name}'s header is expected to exceed ruff's default \
line length, so the suppression is genuinely required; got {} chars: {line:?}",
line.len()
);
assert!(
line.ends_with(" # noqa: E501"),
"{backend_name}: a header longer than ruff's default {RUFF_DEFAULT_LINE_LENGTH} \
columns must carry an E501 suppression, or every generated file fails \
`ruff check --select E` on line 1: {line:?}"
);
}
}
#[test]
fn hash_comment_languages_other_than_python_get_no_suffix() {
let backend = crate::get_backend("ruby-pg", "postgresql").expect("ruby-pg should support postgresql");
let line = header_line(
backend.as_ref(),
"0.13.0",
"postgresql",
"sch1:0123456789abcdef",
"q1:fedcba9876543210",
);
assert_eq!(
line,
"# scythe:provenance v=0.13.0 backend=ruby-pg engine=postgresql schema=sch1:0123456789abcdef \
queries=q1:fedcba9876543210\n"
);
}
#[test]
fn sanitize_field_escapes_newline_carriage_return_and_space() {
assert_eq!(
sanitize_field("postgresql\nfn evil() {}"),
r"postgresql\u{a}fn\u{20}evil()\u{20}{}"
);
assert_eq!(
sanitize_field("postgresql\r\nfn evil() {}"),
r"postgresql\u{d}\u{a}fn\u{20}evil()\u{20}{}"
);
assert_eq!(
sanitize_field("postgresql\rfn evil() {}"),
r"postgresql\u{d}fn\u{20}evil()\u{20}{}"
);
assert_eq!(sanitize_field("clean"), "clean");
}
#[test]
fn sanitize_field_output_never_contains_whitespace_or_control_characters() {
for value in ADVERSARIAL_FIELD_VALUES {
let encoded = sanitize_field(value);
assert!(
!encoded.contains(|c: char| c.is_whitespace() || c.is_control()),
"encoding {value:?} left whitespace or a control character in {encoded:?}"
);
}
}
#[test]
fn sanitize_field_escapes_the_escape_introducer() {
assert_eq!(sanitize_field(r"c:\tmp"), r"c:\\tmp");
assert_eq!(sanitize_field(r"\u{20}"), r"\\u{20}");
assert_eq!(decode_field(&sanitize_field(r"\u{20}")), r"\u{20}");
}
#[test]
fn decode_field_is_the_identity_for_old_format_header_values() {
for value in [
"0.13.0",
"0.15.0-rc.1",
"rust-sqlx",
"python-psycopg3",
"postgresql",
"mariadb",
"sch1:0123456789abcdef",
"q1:fedcba9876543210",
] {
assert_eq!(decode_field(value).as_ref(), value);
assert!(matches!(decode_field(value), Cow::Borrowed(_)), "{value:?} reallocated");
}
}
#[test]
fn decode_field_treats_an_unrecognized_escape_as_a_literal_backslash() {
assert_eq!(decode_field(r"c:\tmp"), r"c:\tmp");
assert_eq!(decode_field(r"trailing\"), "trailing\\");
assert_eq!(decode_field(r"\u{}"), r"\u{}");
assert_eq!(decode_field(r"\u{zz}"), r"\u{zz}");
assert_eq!(decode_field(r"\u{d800}"), r"\u{d800}", "lone surrogate is not a char");
assert_eq!(decode_field(r"\u{20"), r"\u{20", "unterminated escape");
}
const ADVERSARIAL_FIELD_VALUES: &[&str] = &[
"",
"postgresql",
"sql/my schema.sql",
"a b c",
" leading",
"trailing ",
"\ttab\t",
"line\nbreak",
"carriage\rreturn",
"crlf\r\nboth",
"nbsp\u{a0}space",
"vertical\u{b}tab",
"key=value=pairs",
"=starts-with-equals",
"no-equals-at-all",
r"back\slash",
r"\\double",
r"\u{20}literal-escape",
"\"quoted\"",
"'single'",
"scythe:provenance v=9.9.9",
"// scythe:provenance",
"# noqa: E501",
"ünïcödé-ロケール",
"emoji-🎉-field",
"\u{0}nul",
"brace}close",
];
#[test]
fn header_line_round_trips_adversarial_field_values() {
for backend_name in ["rust-sqlx", "python-psycopg3"] {
let backend = crate::get_backend(backend_name, "postgresql")
.unwrap_or_else(|e| panic!("{backend_name} with postgresql: {e}"));
let comment = comment_prefix(&backend.manifest().backend.language);
for version in ADVERSARIAL_FIELD_VALUES {
for other in ADVERSARIAL_FIELD_VALUES {
let line = header_line(backend.as_ref(), version, other, version, other);
assert_eq!(
line.lines().count(),
1,
"{backend_name}: header must stay one line for {version:?}/{other:?}, got {line:?}"
);
assert!(
line.starts_with(comment),
"{backend_name}: header must stay a comment, got {line:?}"
);
let fields =
parse_header_fields(&line).unwrap_or_else(|| panic!("{backend_name}: no sentinel in {line:?}"));
let read = |key: &str| {
fields
.iter()
.find(|(k, _)| *k == key)
.map(|(_, v)| v.as_str())
.unwrap_or_else(|| panic!("{backend_name}: no {key}= in {line:?}"))
};
assert_eq!(read("v"), *version, "version round trip, line: {line:?}");
assert_eq!(read("backend"), backend.name(), "backend round trip, line: {line:?}");
assert_eq!(read("engine"), *other, "engine round trip, line: {line:?}");
assert_eq!(read("schema"), *version, "schema round trip, line: {line:?}");
assert_eq!(read("queries"), *other, "queries round trip, line: {line:?}");
}
}
}
}
#[test]
fn assembled_file_round_trips_a_space_bearing_field_value() {
let backend = crate::get_backend("rust-sqlx", "postgresql").expect("rust-sqlx should support postgresql");
let engine = "postgresql\nfn evil() {}";
let file = assemble_file(
"",
&header_line(
backend.as_ref(),
"0.15.0",
engine,
"sch1:0123456789abcdef",
"q1:fedcba9876543210",
),
"pub fn generated() {}\n",
);
assert!(
!file.lines().any(|line| line.trim_start().starts_with("fn evil()")),
"injected content must never appear as its own uncommented line:\n{file}"
);
let header = file
.lines()
.find(|line| line.contains(HEADER_SENTINEL))
.expect("assembled file must carry a header line");
let fields = parse_header_fields(header).expect("header line must parse");
let parsed_engine = fields
.iter()
.find(|(key, _)| *key == "engine")
.map(|(_, value)| value.as_str());
assert_eq!(parsed_engine, Some(engine), "header: {header:?}");
}
#[test]
fn parse_header_fields_skips_the_python_noqa_suffix() {
let backend =
crate::get_backend("python-psycopg3", "postgresql").expect("python-psycopg3 should support postgresql");
let line = header_line(
backend.as_ref(),
"0.15.0",
"postgresql",
"sch1:0123456789abcdef",
"q1:fedcba9876543210",
);
let keys: Vec<&str> = parse_header_fields(&line)
.expect("header line must parse")
.into_iter()
.map(|(key, _)| key)
.collect();
assert_eq!(keys, vec!["v", "backend", "engine", "schema", "queries"]);
}
#[test]
fn parse_header_fields_returns_none_without_the_sentinel() {
assert!(parse_header_fields("// just a comment v=1.2.3").is_none());
}
#[test]
fn parse_header_fields_preserves_unknown_keys() {
let fields =
parse_header_fields("// scythe:provenance v=0.15.0 future_field=xyz").expect("header line must parse");
assert_eq!(
fields,
vec![("v", "0.15.0".to_string()), ("future_field", "xyz".to_string())]
);
}
#[test]
fn sanitize_field_is_a_no_op_for_every_alias_get_backend_accepts() {
for alias in [
"postgresql",
"postgres",
"pg",
"cockroachdb",
"crdb",
"mysql",
"mariadb",
"sqlite",
"sqlite3",
"duckdb",
"mssql",
"sqlserver",
"tsql",
"oracle",
"snowflake",
"redshift",
] {
assert_eq!(sanitize_field(alias).as_ref(), alias);
}
}
#[test]
fn header_line_embeds_the_caller_supplied_version_not_this_crates_own() {
let backend = crate::get_backend("rust-sqlx", "postgresql").expect("rust-sqlx should support postgresql");
let line = header_line(
backend.as_ref(),
"9.9.9-caller",
"postgresql",
"sch1:0123456789abcdef",
"q1:fedcba9876543210",
);
assert_eq!(
line,
"// scythe:provenance v=9.9.9-caller backend=rust-sqlx engine=postgresql schema=sch1:0123456789abcdef \
queries=q1:fedcba9876543210\n"
);
}
#[test]
fn assemble_file_omits_separator_when_preamble_is_empty() {
assert_eq!(assemble_file("", "// header\n", "body\n"), "// header\nbody\n");
}
#[test]
fn assemble_file_inserts_separator_when_preamble_is_non_empty() {
assert_eq!(
assemble_file("<?php\n", "// header\n", "declare(strict_types=1);\n"),
"<?php\n// header\n\ndeclare(strict_types=1);\n"
);
}
#[test]
fn header_line_is_byte_identical_to_the_pre_options_five_field_form() {
let backend = crate::get_backend("rust-sqlx", "postgresql").expect("rust-sqlx should support postgresql");
let line = header_line(
backend.as_ref(),
"0.15.0",
"postgresql",
"sch1:0123456789abcdef",
"q1:fedcba9876543210",
);
assert_eq!(
line,
"// scythe:provenance v=0.15.0 backend=rust-sqlx engine=postgresql schema=sch1:0123456789abcdef \
queries=q1:fedcba9876543210\n"
);
}
#[test]
fn header_line_with_options_appends_the_sixth_field_before_the_suffix() {
let backend = crate::get_backend("rust-sqlx", "postgresql").expect("rust-sqlx should support postgresql");
let line = header_line_with_options(
backend.as_ref(),
"0.15.0",
"postgresql",
"sch1:0123456789abcdef",
"q1:fedcba9876543210",
"opt1:0011223344556677",
);
assert_eq!(
line,
"// scythe:provenance v=0.15.0 backend=rust-sqlx engine=postgresql schema=sch1:0123456789abcdef \
queries=q1:fedcba9876543210 options=opt1:0011223344556677\n"
);
}
#[test]
fn header_line_with_options_keeps_the_python_suffix_last() {
let backend =
crate::get_backend("python-psycopg3", "postgresql").expect("python-psycopg3 should support postgresql");
let line = header_line_with_options(
backend.as_ref(),
"0.15.0",
"postgresql",
"sch1:0123456789abcdef",
"q1:fedcba9876543210",
"opt1:0011223344556677",
);
assert!(
line.ends_with("options=opt1:0011223344556677 # noqa: E501\n"),
"got: {line:?}"
);
}
#[test]
fn header_line_with_options_round_trips_through_parse_header_fields() {
let backend = crate::get_backend("rust-sqlx", "postgresql").expect("rust-sqlx should support postgresql");
let line = header_line_with_options(
backend.as_ref(),
"0.15.0",
"postgresql",
"sch1:0123456789abcdef",
"q1:fedcba9876543210",
"opt1:0011223344556677",
);
let fields = parse_header_fields(&line).expect("header line must parse");
let options = fields
.iter()
.find(|(key, _)| *key == "options")
.map(|(_, value)| value.as_str());
assert_eq!(options, Some("opt1:0011223344556677"));
}
#[test]
fn options_fingerprint_has_the_opt1_tag_shape() {
let fp = options_fingerprint([("row_type", "pydantic")], None);
assert!(fp.starts_with("opt1:"), "got: {fp:?}");
assert_eq!(
fp.len(),
"opt1:".len() + 16,
"expected a 16-hex-digit digest after the tag, got: {fp:?}"
);
}
#[test]
fn options_fingerprint_is_independent_of_input_order() {
let forward = options_fingerprint([("derive", "Debug"), ("row_type", "pydantic"), ("serde", "true")], None);
let backward = options_fingerprint([("serde", "true"), ("row_type", "pydantic"), ("derive", "Debug")], None);
assert_eq!(forward, backward);
}
#[test]
fn options_fingerprint_is_deterministic_across_repeated_calls() {
let a = options_fingerprint(
[("row_type", "pydantic")],
Some("[naming]\nstruct_case = \"PascalCase\"\n"),
);
let b = options_fingerprint(
[("row_type", "pydantic")],
Some("[naming]\nstruct_case = \"PascalCase\"\n"),
);
assert_eq!(a, b);
}
#[test]
fn options_fingerprint_changes_when_an_option_value_changes() {
let pydantic = options_fingerprint([("row_type", "pydantic")], None);
let dataclass = options_fingerprint([("row_type", "dataclass")], None);
assert_ne!(pydantic, dataclass, "a changed option value must move the fingerprint");
}
#[test]
fn options_fingerprint_changes_when_an_option_is_added_or_removed() {
let one_option = options_fingerprint([("row_type", "pydantic")], None);
let two_options = options_fingerprint([("row_type", "pydantic"), ("serde", "true")], None);
assert_ne!(one_option, two_options, "an added option must move the fingerprint");
}
#[test]
fn options_fingerprint_changes_when_the_manifest_overlay_gains_content() {
let no_overlay = options_fingerprint([("row_type", "pydantic")], None);
let with_overlay = options_fingerprint(
[("row_type", "pydantic")],
Some("[naming]\nstruct_case = \"snake_case\"\n"),
);
assert_ne!(
no_overlay, with_overlay,
"configuring a manifest overlay must move the fingerprint even when `options` is unchanged"
);
}
#[test]
fn options_fingerprint_changes_when_the_manifest_overlay_content_changes() {
let before = options_fingerprint(
[("row_type", "pydantic")],
Some("[naming]\nstruct_case = \"PascalCase\"\n"),
);
let after = options_fingerprint(
[("row_type", "pydantic")],
Some("[naming]\nstruct_case = \"snake_case\"\n"),
);
assert_ne!(before, after);
}
#[test]
fn options_fingerprint_of_no_options_and_no_overlay_is_deterministic() {
assert_eq!(
options_fingerprint(std::iter::empty(), None),
options_fingerprint(std::iter::empty(), None)
);
}
#[test]
fn options_fingerprint_is_not_forgeable_by_a_value_containing_a_delimiter() {
let real_two_options = options_fingerprint([("a", "1"), ("b", "2")], None);
let forged_single_option = options_fingerprint([("a", "1\nopt\tb\t2")], None);
assert_ne!(
real_two_options, forged_single_option,
"a value containing a raw newline/tab must not be able to forge an extra `opt` line"
);
}
}