Skip to main content

scythe_codegen/
provenance.rs

1//! The provenance header line every generated file carries, and the exact
2//! byte ordering it sits in.
3//!
4//! This lives in `scythe-codegen` rather than in the `scythe` CLI that
5//! actually writes the files, for one reason: `tests/tool_validation.rs` in
6//! this crate hands assembled files to the real language tools (`php -l`,
7//! `ruby -c`, `gofmt`, ...), and it can only substantiate the claim that the
8//! header sits in a position each language accepts if it assembles the same
9//! bytes the CLI writes. A second copy of the comment-prefix table, or of
10//! the preamble/header/body ordering, kept privately in the test harness,
11//! would drift from the real one silently — and it would drift precisely in
12//! the case the harness exists to catch.
13//!
14//! The same argument is why the *reader* half — [`sanitize_field`]'s
15//! inverse [`decode_field`], and the [`parse_header_fields`] tokenizer that
16//! applies it — sits here beside [`header_line`] rather than beside the
17//! verifier that consumes it. A writer and a reader of one format are two
18//! derivations of the same fact; kept apart, each can only be tested
19//! against a hand-written expected string, and both tests keep passing
20//! while the pair drifts. Kept together, one test asserts the round trip.
21
22use std::borrow::Cow;
23use std::fmt::Write as _;
24
25use crate::backend_trait::CodegenBackend;
26
27/// The token every provenance header line is built around, and the anchor a
28/// reader locates the `key=value` tail from.
29///
30/// Deliberately comment-syntax-agnostic: the header sits behind whatever
31/// comment token the target language uses (`//`, `#`, `--`, a block
32/// comment, ...), so a reader finds this substring and treats everything
33/// after it on that line as the field list, instead of needing a
34/// per-language rule for stripping each comment syntax.
35pub const HEADER_SENTINEL: &str = "scythe:provenance";
36
37/// Line-comment token to embed the provenance header behind, derived from
38/// `manifest().backend.language`.
39///
40/// `language` is a `String`, not a Rust enum, but exactly 10 distinct values
41/// are used across every one of the 102 shipped manifests, so this one match
42/// table is the single place backends' comment syntax is derived from —
43/// no per-backend declaration, and no manifest schema change. Unrecognized
44/// values fall back to `//`: every backend shipped today matches one of the
45/// listed languages, and `//` is a safe default for any future one that
46/// doesn't.
47pub fn comment_prefix(language: &str) -> &'static str {
48    match language {
49        "python" | "ruby" | "elixir" => "#",
50        _ => "//",
51    }
52}
53
54/// Trailing text appended to the header line for languages whose *default*
55/// linter configuration rejects it, derived from
56/// `manifest().backend.language` the same way [`comment_prefix`] is.
57///
58/// Python only, for one concrete reason: ruff's default `line-length` is 88,
59/// while the header runs to roughly 99 characters (`# scythe:provenance
60/// v=0.13.0 backend=python-psycopg3 engine=postgresql schema=sch1:` plus 16
61/// hex digits). Without a suppression, *every* file scythe generates for a
62/// Python target reports `E501 Line too long` on line 1 under `ruff check
63/// --select E` — a lint error the consumer did not write and cannot fix
64/// without either hand-editing generated code or loosening their own
65/// line-length. `# noqa: E501` is the same mechanism, and the same
66/// two-spaces-before-`#` spelling, that scythe's Python backends already use
67/// for the `# noqa: F401` on their conditionally-emitted imports.
68///
69/// The suffix is invisible to a reader: [`parse_header_fields`] tokenizes
70/// the text after the sentinel on whitespace and skips any token without an
71/// `=`, so `#`, `noqa:`, and `E501` are all ignored and the five
72/// `key=value` fields still parse. Pinned by
73/// `parse_header_fields_skips_the_python_noqa_suffix` below, and by
74/// `assemble_output_python_header_carries_noqa_and_still_round_trips` in
75/// `scythe-cli`.
76///
77/// Shortening the header instead was not an option: the width is driven by
78/// the backend name, the engine alias, and a 16-hex-digit fingerprint, none
79/// of which can be trimmed without losing information the verifier compares.
80///
81/// Known trade-off: a project that both raises `line-length` past ~99 *and*
82/// opts into `RUF100` (unused-noqa) will see the suppression reported as
83/// unnecessary. That is strictly the lesser problem — it takes an opt-in
84/// rule plus a non-default line length to hit, whereas E501 fires on ruff's
85/// defaults — and it is the same trade-off the existing `# noqa: F401`
86/// lines already make.
87fn header_suffix(language: &str) -> &'static str {
88    match language {
89        "python" => "  # noqa: E501",
90        _ => "",
91    }
92}
93
94/// True for the characters [`sanitize_field`] must escape: the escape
95/// introducer itself, plus everything that would break the two structural
96/// guarantees the header format rests on.
97///
98/// - **Whitespace** would break field framing. The header tail is a
99///   whitespace-separated list of `key=value` tokens, so a raw space inside
100///   a value splits that value into a token with no `=` — which a reader
101///   drops on the floor, silently truncating the field (#133). `is_control`
102///   and `is_whitespace` together cover more than ASCII space: `\n` and `\r`
103///   would terminate the comment outright (below), `\t`/`\x0b`/`\x0c` split
104///   under `split_ascii_whitespace`, and a *Unicode* space such as U+00A0
105///   at the very end of the last value is eaten by the `str::trim` a reader
106///   applies to the tail. All of them are escaped so no reader has to care
107///   which class a given character falls in.
108/// - **Line terminators** would break the "the header is always a comment"
109///   guarantee. A value containing `\n` or a lone `\r` ends the comment
110///   early: everything after it lands on its own physical line with no
111///   comment prefix, becoming live, uncommented content in the generated
112///   file.
113fn needs_escape(character: char) -> bool {
114    character == '\\' || character.is_whitespace() || character.is_control()
115}
116
117/// Encode a provenance field value so it can be embedded in the header line
118/// and read back byte-for-byte. The exact inverse of [`decode_field`].
119///
120/// Escapes with a backslash: `\` becomes `\\`, and any character
121/// [`needs_escape`] flags becomes `\u{<hex>}` (e.g. a space becomes
122/// `\u{20}`). The result therefore contains no whitespace and no control
123/// characters at all, which is what makes the header a well-framed,
124/// single-line, always-a-comment token list regardless of what a caller
125/// passes in.
126///
127/// In practice this is the identity function. Every value that reaches
128/// [`header_line`] today — a semver `version`, a hardcoded `backend.name()`
129/// literal, an `engine` alias, `sch1:` + 16 hex, `q1:` + 16 hex — contains
130/// none of the escaped characters, so no shipped header's bytes change and
131/// no `Cow` is ever allocated. The escaping exists for the one field that
132/// is *not* constrained: `engine` is the `[[sql]]` `engine = "..."` value,
133/// deserialized verbatim from the user's `scythe.toml` with no validation
134/// upstream (`normalize_engine` only consults it to pick a dialect; the raw
135/// string is what a caller passes through to here). Encoding at the point
136/// of embedding, rather than at config parse time, means the guarantee
137/// holds regardless of how a value arrives — this call site today, or any
138/// future one — not just for callers that happen to validate first.
139///
140/// The verifier (`scythe check`) must encode its configured engine the same
141/// way before comparing it against the header's `engine` field: the header
142/// always holds the encoded value, so comparing it against a raw one would
143/// permanently false-flag SC-PRV04 for any config whose engine string
144/// needed escaping. Comparing encoded-to-encoded is equivalent to comparing
145/// decoded-to-decoded, because the encoding is injective.
146pub fn sanitize_field(value: &str) -> Cow<'_, str> {
147    if !value.contains(needs_escape) {
148        return Cow::Borrowed(value);
149    }
150
151    let mut encoded = String::with_capacity(value.len());
152    for character in value.chars() {
153        match character {
154            '\\' => encoded.push_str(r"\\"),
155            character if needs_escape(character) => {
156                write!(encoded, "\\u{{{:x}}}", character as u32).expect("writing to a String cannot fail");
157            }
158            character => encoded.push(character),
159        }
160    }
161    Cow::Owned(encoded)
162}
163
164/// Decode a `u{<hex>}` escape body sitting immediately after a backslash,
165/// returning the character it denotes and the input remaining after the
166/// closing brace. `None` if `after_backslash` does not open a well-formed,
167/// in-range escape.
168fn decode_unicode_escape(after_backslash: &str) -> Option<(char, &str)> {
169    let body = after_backslash.strip_prefix("u{")?;
170    let close = body.find('}')?;
171    let code_point = u32::from_str_radix(&body[..close], 16).ok()?;
172    Some((char::from_u32(code_point)?, &body[close + 1..]))
173}
174
175/// Recover the original field value from what [`sanitize_field`] embedded.
176/// The exact inverse of that function — the round trip is pinned by
177/// `header_line_round_trips_adversarial_field_values` below.
178///
179/// Lenient by design, which is what keeps every header committed before
180/// this encoding existed readable: a backslash that does not introduce a
181/// recognized escape decodes to a literal backslash rather than an error.
182/// Old-format headers are unaffected in a stronger sense than "tolerated" —
183/// their values (semver, backend literal, engine alias, `sch1:`/`q1:` plus
184/// hex) contain no backslash at all, so decoding them is bit-for-bit the
185/// identity. Rejecting a lone backslash instead would buy nothing: there is
186/// no format-version field to switch on, so a stricter reader could only
187/// turn an old file into a hard error where today it reads correctly.
188pub fn decode_field(value: &str) -> Cow<'_, str> {
189    if !value.contains('\\') {
190        return Cow::Borrowed(value);
191    }
192
193    let mut decoded = String::with_capacity(value.len());
194    let mut rest = value;
195    while let Some(backslash) = rest.find('\\') {
196        decoded.push_str(&rest[..backslash]);
197        let after_backslash = &rest[backslash + 1..];
198
199        if let Some(tail) = after_backslash.strip_prefix('\\') {
200            decoded.push('\\');
201            rest = tail;
202        } else if let Some((character, tail)) = decode_unicode_escape(after_backslash) {
203            decoded.push(character);
204            rest = tail;
205        } else {
206            decoded.push('\\');
207            rest = after_backslash;
208        }
209    }
210    decoded.push_str(rest);
211    Cow::Owned(decoded)
212}
213
214/// Read back the `key=value` fields [`header_line`] wrote, decoded.
215///
216/// This is the parsing half of the header format, and it lives here rather
217/// than beside its caller for the same reason the rest of this module does:
218/// the emitted line and the reader that consumes it are two derivations of
219/// one fact, and a private second copy of the tokenizer would drift from
220/// the writer silently — in exactly the case that matters. Keeping the pair
221/// adjacent is what lets one test assert the round trip
222/// (`header_line_round_trips_adversarial_field_values`) instead of two
223/// tests each asserting half of it against a hand-written string.
224///
225/// `scythe check` still reads headers through its own private tokenizer in
226/// `scythe-cli`, which does not decode. That stays correct rather than
227/// merely tolerable: it compares the field it read against the *encoded*
228/// configured value (it runs that value through [`sanitize_field`] first),
229/// and comparing encoded-to-encoded is equivalent to comparing the decoded
230/// values because the encoding is injective. Adopting this function there
231/// buys better wording in a drift message, not different verdicts.
232///
233/// Returns `None` when `line` carries no [`HEADER_SENTINEL`] at all. Keys
234/// are returned in the order they appear, borrowed from `line`; values are
235/// [`decode_field`]-decoded. Tokens with no `=` are skipped, which is what
236/// makes the per-language [`header_suffix`] (Python's `# noqa: E501`)
237/// invisible to a reader. Unknown keys are returned rather than rejected:
238/// deciding which keys matter belongs to the caller, so the header format
239/// and any given verifier can grow independently.
240pub fn parse_header_fields(line: &str) -> Option<Vec<(&str, String)>> {
241    let sentinel_start = line.find(HEADER_SENTINEL)?;
242    let tail = line[sentinel_start + HEADER_SENTINEL.len()..].trim();
243
244    Some(
245        tail.split_ascii_whitespace()
246            .filter_map(|token| token.split_once('='))
247            .map(|(key, value)| (key, decode_field(value).into_owned()))
248            .collect(),
249    )
250}
251
252/// Build the provenance header line that [`assemble_file`] prepends to every
253/// generated file, right after [`CodegenBackend::file_preamble`] and before
254/// [`CodegenBackend::file_header`]: the sentinel `scythe check` searches for,
255/// commented out using the target language's own line-comment syntax so it
256/// reads as an ordinary comment to every downstream compiler, formatter, and
257/// human.
258///
259/// `backend.name()` (not the raw `[[sql.gen]]` `backend = "..."` config
260/// value) is what gets embedded — `get_backend` accepts several aliases per
261/// backend (`"sqlx"`, `"rust"`, and `"rust-sqlx"` all construct the same
262/// backend), and `name()` is the one canonical form every alias agrees on.
263/// The verifier's SC-PRV03 check compares against this same `backend.name()`,
264/// not the config alias, for exactly this reason.
265///
266/// `version` is supplied by the caller rather than read from this crate's
267/// own `CARGO_PKG_VERSION`. The number that belongs in the header is the
268/// version of the `scythe` binary that produced the file, because that is
269/// the number `scythe check` compares against; `scythe-codegen` happens to
270/// share the workspace version today, but baking its own constant in here
271/// would silently embed the wrong one the moment the two diverge.
272///
273/// `queries` is the `q1:<16 hex>` fingerprint of the analyzed query set that
274/// produced this file (see `AnalyzedQuery::fingerprint_set` in
275/// `scythe-core`), the `queries=` counterpart to `schema`'s `sch1:<16 hex>`
276/// — added in #94 so that editing a `.sql` query file without touching the
277/// schema is no longer invisible to `scythe check`.
278///
279/// *Every* field is passed through [`sanitize_field`] before embedding, not
280/// just the free-form `engine` one. Four of the five cannot contain an
281/// escapable character today, so encoding them is provably a no-op and the
282/// emitted bytes are unchanged — but "cannot" is a property of the current
283/// callers, not of this function's signature, which accepts an arbitrary
284/// `&str` for each. Encoding uniformly means [`parse_header_fields`] is the
285/// exact inverse of this function for *any* input, which is what the
286/// round-trip test can assert; encoding selectively would make the inverse
287/// hold only for the argument positions someone remembered to cover.
288///
289/// A per-language [`header_suffix`] may be appended after the last field —
290/// today only Python's `# noqa: E501`, without which the line trips ruff's
291/// default 88-character limit in every generated file.
292pub fn header_line(backend: &dyn CodegenBackend, version: &str, engine: &str, schema: &str, queries: &str) -> String {
293    build_header_line(backend, version, engine, schema, queries, None)
294}
295
296/// [`header_line`]'s sibling for the `options=` sixth field (GH #155):
297/// a fingerprint of a `[[sql.gen]]` target's resolved options (derive list,
298/// serde flag, `row_type`, naming case, ...) and, when one is configured,
299/// its manifest overlay's contents -- see [`options_fingerprint`], which
300/// produces the `options` value this function embeds.
301///
302/// A separate function rather than a new required parameter on
303/// [`header_line`] deliberately: dozens of call sites across
304/// `scythe-codegen`'s own regression tests construct a header with the
305/// original five-argument form to build an *expected* string for structural
306/// validation, not to assert on the header's exact bytes, and those files
307/// are generated by `tools/integration-test-generator` -- not hand-edited,
308/// so their call sites cannot be updated here. Changing `header_line`'s
309/// arity would have been a breaking change this crate cannot fix at every
310/// call site. Delegating both functions to the same private
311/// [`build_header_line`] keeps the five-field and six-field forms unable to
312/// drift from each other on everything but the one field that differs.
313pub fn header_line_with_options(
314    backend: &dyn CodegenBackend,
315    version: &str,
316    engine: &str,
317    schema: &str,
318    queries: &str,
319    options: &str,
320) -> String {
321    build_header_line(backend, version, engine, schema, queries, Some(options))
322}
323
324/// Shared implementation behind [`header_line`] (`options: None`) and
325/// [`header_line_with_options`] (`options: Some(...)`). `options.is_none()`
326/// produces bytes identical to the pre-#155 five-field header -- the
327/// `options=` field, and the space before it, are only ever emitted when a
328/// value is supplied -- so this refactor is behavior-preserving for every
329/// existing [`header_line`] caller.
330fn build_header_line(
331    backend: &dyn CodegenBackend,
332    version: &str,
333    engine: &str,
334    schema: &str,
335    queries: &str,
336    options: Option<&str>,
337) -> String {
338    let language = &backend.manifest().backend.language;
339    let comment = comment_prefix(language);
340    let suffix = header_suffix(language);
341    let version = sanitize_field(version);
342    let name = sanitize_field(backend.name());
343    let engine = sanitize_field(engine);
344    let schema = sanitize_field(schema);
345    let queries = sanitize_field(queries);
346    let options_field = match options {
347        Some(value) => format!(" options={}", sanitize_field(value)),
348        None => String::new(),
349    };
350    format!(
351        "{comment} {HEADER_SENTINEL} v={version} backend={name} engine={engine} schema={schema} \
352         queries={queries}{options_field}{suffix}\n"
353    )
354}
355
356/// Version tag for the gen-target options fingerprint algorithm, mirroring
357/// `q1`/`sch1`'s role for the query/schema fingerprints in `scythe-core`.
358const OPTIONS_FINGERPRINT_ALGORITHM_TAG: &str = "opt1";
359
360/// FNV-1a, 64-bit variant.
361///
362/// Chosen over a cryptographic hash so [`options_fingerprint`] costs this
363/// crate no new dependency: `scythe-codegen`'s `Cargo.toml` carries none
364/// today, and `scythe-core`'s SHA-256 (used for the sibling schema/query
365/// fingerprints) is private to that crate. FNV-1a takes no seed and reads no
366/// process or environment state -- its output is a pure function of the
367/// input bytes, which is exactly the property this fingerprint needs: the
368/// same option set must hash identically on every machine and every run,
369/// forever. A collision only under-reports drift (treats two different
370/// option sets as the same), never over-reports it, which is an acceptable
371/// failure mode for a hint that `scythe generate` fixes either way -- this
372/// is not a security boundary a cryptographic hash exists to defend.
373fn fnv1a_64(bytes: &[u8]) -> u64 {
374    const OFFSET_BASIS: u64 = 0xcbf29ce484222325;
375    const PRIME: u64 = 0x0000_0100_0000_01b3;
376
377    let mut hash = OFFSET_BASIS;
378    for &byte in bytes {
379        hash ^= u64::from(byte);
380        hash = hash.wrapping_mul(PRIME);
381    }
382    hash
383}
384
385/// The `options=` field [`header_line_with_options`] embeds: a deterministic
386/// fingerprint of a `[[sql.gen]]` target's resolved options, plus the
387/// contents of its manifest overlay when one is configured (GH #155).
388///
389/// Before this existed, the header carried five fields -- version, backend,
390/// engine, schema, queries -- and none of them moved when only an *option*
391/// changed: flipping `row_type`, adding a `derive`, or swapping in a
392/// different `manifest = "..."` overlay left the header byte-identical, so
393/// `scythe check` reported a stale artifact as fresh.
394///
395/// `options` is any iterator of `(key, value)` pairs -- a caller typically
396/// passes a `[[sql.gen]]` target's resolved `HashMap<String, String>` by
397/// reference (`.iter().map(|(k, v)| (k.as_str(), v.as_str()))`); sorting
398/// happens inside this function so the caller never has to remember to do
399/// it. `manifest_overlay` is the *contents* of the overlay file when
400/// `[[sql.gen]] manifest = "..."` names one, `None` otherwise --
401/// fingerprinting the file's bytes, not its path, so drift is detected when
402/// the overlay's *content* changes and is never spuriously reported just
403/// because the same project was checked out to a different path or on a
404/// different machine.
405///
406/// # Stability
407///
408/// - **Order-independent.** `options` is sorted by key before it is hashed,
409///   so it does not matter whether the caller's `HashMap` (whose iteration
410///   order is unspecified, and in practice randomized per process by
411///   `std`'s default hasher) happened to yield its entries in one order or
412///   another on a given run.
413/// - **Machine- and run-independent.** [`fnv1a_64`] takes no seed and reads
414///   no environment -- see that function's doc comment. This is exactly why
415///   `ahash` (already a dependency of both this crate and `scythe-cli`,
416///   whose `AHashSet`/`AHashMap` are used elsewhere in this codebase) was
417///   deliberately *not* reused here: `ahash::AHasher::default()`'s "fixed"
418///   keys are generated once per process from the OS random source (see
419///   `ahash::random_state::get_fixed_seeds`, gated on the `std` feature),
420///   not compiled in, so the identical option set hashes differently
421///   between two separate `scythe generate`/`scythe check` invocations --
422///   which would make every fingerprint here spurious drift on every run.
423/// - **Injective encoding.** Every key, value, and the overlay's content are
424///   each passed through [`sanitize_field`] before being folded into the
425///   canonical form, so no field can inject a fake `\t`/`\n` delimiter and
426///   make two differently-shaped option sets hash the same -- the same
427///   anti-forgery reasoning [`header_line`] already applies to the fields it
428///   embeds.
429/// - **Path-independent.** Only the overlay's *content* is hashed, never its
430///   path -- see above.
431///
432/// The result is `opt1:<16 hex>`, matching the `sch1:`/`q1:` shape of the
433/// sibling schema/query fingerprints so all three read as one family.
434pub fn options_fingerprint<'a, I>(options: I, manifest_overlay: Option<&str>) -> String
435where
436    I: IntoIterator<Item = (&'a str, &'a str)>,
437{
438    let mut sorted: Vec<(&str, &str)> = options.into_iter().collect();
439    sorted.sort_unstable_by_key(|(key, _)| *key);
440
441    let mut canonical = String::new();
442    for (key, value) in sorted {
443        canonical.push_str("opt\t");
444        canonical.push_str(&sanitize_field(key));
445        canonical.push('\t');
446        canonical.push_str(&sanitize_field(value));
447        canonical.push('\n');
448    }
449    if let Some(overlay) = manifest_overlay {
450        canonical.push_str("manifest\t");
451        canonical.push_str(&sanitize_field(overlay));
452        canonical.push('\n');
453    }
454
455    let hash = fnv1a_64(canonical.as_bytes());
456    format!("{OPTIONS_FINGERPRINT_ALGORITHM_TAG}:{hash:016x}")
457}
458
459/// Join a backend's `preamble` (text that must be the literal first bytes of
460/// the file, e.g. PHP's `<?php`), the provenance `header` line, and the
461/// generated `body` into the one byte ordering every generated file uses.
462///
463/// The preamble is unconditionally first — even ahead of the provenance
464/// comment — because the constructs [`CodegenBackend::file_preamble`] carries
465/// lose their meaning if anything at all precedes them (see that method's
466/// doc comment).
467///
468/// The blank line between the header line and the body is emitted only when
469/// `preamble` is non-empty. This is not cosmetic: for the 8 backends with a
470/// preamble override, the old (pre-provenance) `file_header()` text already
471/// opened with its own blank line (PHP's `"<?php\n\ndeclare(...)"`, Ruby's
472/// `"# frozen_string_literal: true\n\n# Auto-generated..."`), so the
473/// separator reproduces those exact bytes once the provenance line is
474/// accounted for. For the other 44 backends, `file_header()` never had a
475/// leading blank line, so an unconditional separator would silently insert
476/// one that was never there before — invisible in the assembled output, but
477/// a real, provable byte-level regression. Conditioning the separator on
478/// `preamble.is_empty()` reproduces the old bytes in both cases:
479/// - preamble non-empty (PHP): `"<?php\n"` + header + `"\n"` + `"declare..."`;
480///   strip the header line → `"<?php\n\ndeclare..."`, the old bytes exactly.
481/// - preamble empty (Go): header + `""` + `"// Code generated..."`;
482///   strip the header line → `"// Code generated..."`, the old bytes exactly.
483pub fn assemble_file(preamble: &str, header: &str, body: &str) -> String {
484    let separator = if preamble.is_empty() { "" } else { "\n" };
485    format!("{preamble}{header}{separator}{body}")
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491
492    #[test]
493    fn comment_prefix_covers_all_ten_manifest_languages() {
494        // The exact 10 values used across every shipped manifest (see
495        // `grep -h '^language' crates/scythe-codegen/manifests/*.toml | sort -u`).
496        let hash_comment = ["python", "ruby", "elixir"];
497        let slash_comment = ["rust", "typescript", "go", "java", "kotlin", "csharp", "php"];
498
499        for language in hash_comment {
500            assert_eq!(comment_prefix(language), "#", "language: {language}");
501        }
502        for language in slash_comment {
503            assert_eq!(comment_prefix(language), "//", "language: {language}");
504        }
505    }
506
507    #[test]
508    fn comment_prefix_defaults_to_slash_slash_for_unknown_language() {
509        assert_eq!(comment_prefix("cobol"), "//");
510    }
511
512    /// Ruff's default `line-length`. The provenance header exceeds it for
513    /// every Python backend, which is the whole reason [`header_suffix`]
514    /// exists; `validate_python_tools` runs `ruff check --select E,F,I` with
515    /// no config file, so this default is what generated files are held to.
516    const RUFF_DEFAULT_LINE_LENGTH: usize = 88;
517
518    /// The suppression is Python-specific and must not leak: `# noqa` is a
519    /// syntax error in most of the other nine languages, and meaningless
520    /// noise in Ruby and Elixir, which share Python's `#` comment token.
521    #[test]
522    fn header_suffix_is_python_only() {
523        assert_eq!(header_suffix("python"), "  # noqa: E501");
524
525        for language in [
526            "ruby",
527            "elixir",
528            "rust",
529            "typescript",
530            "go",
531            "java",
532            "kotlin",
533            "csharp",
534            "php",
535            "cobol",
536        ] {
537            assert_eq!(header_suffix(language), "", "language: {language}");
538        }
539    }
540
541    /// Every Python backend's header line must either fit ruff's default
542    /// line length or carry an `E501` suppression — otherwise `ruff check
543    /// --select E` reports `E501 Line too long` on line 1 of every file
544    /// scythe generates for that backend.
545    ///
546    /// Expressed as the real constraint rather than as "ends with the
547    /// suffix", so it stays honest if a future backend name is short enough
548    /// not to need one. It still fails loudly if the suffix is dropped: the
549    /// shortest Python backend name in the tree today still produces a line
550    /// well past 88 characters, which the premise assertion below pins.
551    #[test]
552    fn python_header_lines_never_trip_ruffs_default_line_length() {
553        for (backend_name, engine) in [
554            ("python-psycopg3", "postgresql"),
555            ("python-asyncpg", "postgresql"),
556            ("python-aiomysql", "mysql"),
557            ("python-aiosqlite", "sqlite"),
558            ("python-duckdb", "duckdb"),
559            ("python-oracledb", "oracle"),
560            ("python-pyodbc", "mssql"),
561            ("python-snowflake", "snowflake"),
562        ] {
563            let backend = crate::get_backend(backend_name, engine)
564                .unwrap_or_else(|e| panic!("{backend_name} with {engine}: {e}"));
565            let line = header_line(
566                backend.as_ref(),
567                "0.13.0",
568                engine,
569                "sch1:0123456789abcdef",
570                "q1:fedcba9876543210",
571            );
572            let line = line.strip_suffix('\n').expect("header line ends with a newline");
573
574            assert!(
575                line.len() > RUFF_DEFAULT_LINE_LENGTH,
576                "test premise: {backend_name}'s header is expected to exceed ruff's default \
577                 line length, so the suppression is genuinely required; got {} chars: {line:?}",
578                line.len()
579            );
580            assert!(
581                line.ends_with("  # noqa: E501"),
582                "{backend_name}: a header longer than ruff's default {RUFF_DEFAULT_LINE_LENGTH} \
583                 columns must carry an E501 suppression, or every generated file fails \
584                 `ruff check --select E` on line 1: {line:?}"
585            );
586        }
587    }
588
589    /// The suppression must not leak into a language that shares Python's
590    /// `#` comment token. Ruby is the case that would actually ship broken:
591    /// its header is emitted into `queries.rb` *and* `queries.rbs`.
592    #[test]
593    fn hash_comment_languages_other_than_python_get_no_suffix() {
594        let backend = crate::get_backend("ruby-pg", "postgresql").expect("ruby-pg should support postgresql");
595        let line = header_line(
596            backend.as_ref(),
597            "0.13.0",
598            "postgresql",
599            "sch1:0123456789abcdef",
600            "q1:fedcba9876543210",
601        );
602
603        assert_eq!(
604            line,
605            "# scythe:provenance v=0.13.0 backend=ruby-pg engine=postgresql schema=sch1:0123456789abcdef \
606             queries=q1:fedcba9876543210\n"
607        );
608    }
609
610    #[test]
611    fn sanitize_field_escapes_newline_carriage_return_and_space() {
612        assert_eq!(
613            sanitize_field("postgresql\nfn evil() {}"),
614            r"postgresql\u{a}fn\u{20}evil()\u{20}{}"
615        );
616        assert_eq!(
617            sanitize_field("postgresql\r\nfn evil() {}"),
618            r"postgresql\u{d}\u{a}fn\u{20}evil()\u{20}{}"
619        );
620        assert_eq!(
621            sanitize_field("postgresql\rfn evil() {}"),
622            r"postgresql\u{d}fn\u{20}evil()\u{20}{}"
623        );
624        assert_eq!(sanitize_field("clean"), "clean");
625    }
626
627    /// The encoded form must contain nothing that can split a token or end
628    /// the comment — that is the entire structural contract the header
629    /// format rests on, and it has to hold for characters no test thought
630    /// to enumerate, not just for `\n`, `\r`, and space.
631    #[test]
632    fn sanitize_field_output_never_contains_whitespace_or_control_characters() {
633        for value in ADVERSARIAL_FIELD_VALUES {
634            let encoded = sanitize_field(value);
635            assert!(
636                !encoded.contains(|c: char| c.is_whitespace() || c.is_control()),
637                "encoding {value:?} left whitespace or a control character in {encoded:?}"
638            );
639        }
640    }
641
642    /// A backslash is the escape introducer, so it must itself be escaped —
643    /// otherwise a value ending in `\` would swallow the `u{...}` of a
644    /// following escape and decode to something else entirely.
645    #[test]
646    fn sanitize_field_escapes_the_escape_introducer() {
647        assert_eq!(sanitize_field(r"c:\tmp"), r"c:\\tmp");
648        assert_eq!(sanitize_field(r"\u{20}"), r"\\u{20}");
649        assert_eq!(decode_field(&sanitize_field(r"\u{20}")), r"\u{20}");
650    }
651
652    /// Headers committed before this encoding existed carry raw values, and
653    /// they must keep reading exactly as they always did. They do so for a
654    /// stronger reason than tolerance: no legal old value contains a
655    /// backslash, so decoding is bit-for-bit the identity. Pinned with the
656    /// real shapes — semver, canonical backend name, engine alias, and the
657    /// two fingerprints.
658    #[test]
659    fn decode_field_is_the_identity_for_old_format_header_values() {
660        for value in [
661            "0.13.0",
662            "0.15.0-rc.1",
663            "rust-sqlx",
664            "python-psycopg3",
665            "postgresql",
666            "mariadb",
667            "sch1:0123456789abcdef",
668            "q1:fedcba9876543210",
669        ] {
670            assert_eq!(decode_field(value).as_ref(), value);
671            assert!(matches!(decode_field(value), Cow::Borrowed(_)), "{value:?} reallocated");
672        }
673    }
674
675    /// A backslash that opens no recognized escape decodes to a literal
676    /// backslash rather than an error or a dropped character — the leniency
677    /// that keeps an unknown or hand-edited header readable.
678    #[test]
679    fn decode_field_treats_an_unrecognized_escape_as_a_literal_backslash() {
680        assert_eq!(decode_field(r"c:\tmp"), r"c:\tmp");
681        assert_eq!(decode_field(r"trailing\"), "trailing\\");
682        assert_eq!(decode_field(r"\u{}"), r"\u{}");
683        assert_eq!(decode_field(r"\u{zz}"), r"\u{zz}");
684        assert_eq!(decode_field(r"\u{d800}"), r"\u{d800}", "lone surrogate is not a char");
685        assert_eq!(decode_field(r"\u{20"), r"\u{20", "unterminated escape");
686    }
687
688    /// Field values chosen to break the header format in every way it can
689    /// be broken: token framing (space, tab, the Unicode space a `trim`
690    /// eats), the "always a comment" guarantee (`\n`, `\r`), the `key=value`
691    /// split (`=`), the escape scheme itself (backslashes, a literal
692    /// `\u{...}`), the sentinel search, and the degenerate empty value.
693    const ADVERSARIAL_FIELD_VALUES: &[&str] = &[
694        "",
695        "postgresql",
696        "sql/my schema.sql",
697        "a b c",
698        " leading",
699        "trailing ",
700        "\ttab\t",
701        "line\nbreak",
702        "carriage\rreturn",
703        "crlf\r\nboth",
704        "nbsp\u{a0}space",
705        "vertical\u{b}tab",
706        "key=value=pairs",
707        "=starts-with-equals",
708        "no-equals-at-all",
709        r"back\slash",
710        r"\\double",
711        r"\u{20}literal-escape",
712        "\"quoted\"",
713        "'single'",
714        "scythe:provenance v=9.9.9",
715        "// scythe:provenance",
716        "# noqa: E501",
717        "ünïcödé-ロケール",
718        "emoji-🎉-field",
719        "\u{0}nul",
720        "brace}close",
721    ];
722
723    /// The invariant this module exists to hold: whatever a caller puts in,
724    /// a reader gets back, byte for byte, for every field. [`header_line`]
725    /// and [`parse_header_fields`] are two derivations of one format, and
726    /// this is the only test that can catch them disagreeing — a test that
727    /// checked either half against a hand-written expected string would
728    /// pass happily while the pair drifted.
729    ///
730    /// Also asserts the two structural guarantees a caller cannot recover
731    /// from if they are lost: the header stays exactly one line, and it
732    /// stays behind a comment token.
733    #[test]
734    fn header_line_round_trips_adversarial_field_values() {
735        // Two backends, because the emitted line differs by language:
736        // `python-psycopg3` appends `# noqa: E501` after the last field,
737        // which the reader must skip rather than read as a field.
738        for backend_name in ["rust-sqlx", "python-psycopg3"] {
739            let backend = crate::get_backend(backend_name, "postgresql")
740                .unwrap_or_else(|e| panic!("{backend_name} with postgresql: {e}"));
741            let comment = comment_prefix(&backend.manifest().backend.language);
742
743            for version in ADVERSARIAL_FIELD_VALUES {
744                for other in ADVERSARIAL_FIELD_VALUES {
745                    let line = header_line(backend.as_ref(), version, other, version, other);
746
747                    assert_eq!(
748                        line.lines().count(),
749                        1,
750                        "{backend_name}: header must stay one line for {version:?}/{other:?}, got {line:?}"
751                    );
752                    assert!(
753                        line.starts_with(comment),
754                        "{backend_name}: header must stay a comment, got {line:?}"
755                    );
756
757                    let fields =
758                        parse_header_fields(&line).unwrap_or_else(|| panic!("{backend_name}: no sentinel in {line:?}"));
759                    let read = |key: &str| {
760                        fields
761                            .iter()
762                            .find(|(k, _)| *k == key)
763                            .map(|(_, v)| v.as_str())
764                            .unwrap_or_else(|| panic!("{backend_name}: no {key}= in {line:?}"))
765                    };
766
767                    assert_eq!(read("v"), *version, "version round trip, line: {line:?}");
768                    assert_eq!(read("backend"), backend.name(), "backend round trip, line: {line:?}");
769                    assert_eq!(read("engine"), *other, "engine round trip, line: {line:?}");
770                    assert_eq!(read("schema"), *version, "schema round trip, line: {line:?}");
771                    assert_eq!(read("queries"), *other, "queries round trip, line: {line:?}");
772                }
773            }
774        }
775    }
776
777    /// The round trip must also survive assembly, where the header is one
778    /// line among many rather than the whole string — the shape a verifier
779    /// actually reads off disk.
780    #[test]
781    fn assembled_file_round_trips_a_space_bearing_field_value() {
782        let backend = crate::get_backend("rust-sqlx", "postgresql").expect("rust-sqlx should support postgresql");
783        let engine = "postgresql\nfn evil() {}";
784
785        let file = assemble_file(
786            "",
787            &header_line(
788                backend.as_ref(),
789                "0.15.0",
790                engine,
791                "sch1:0123456789abcdef",
792                "q1:fedcba9876543210",
793            ),
794            "pub fn generated() {}\n",
795        );
796
797        assert!(
798            !file.lines().any(|line| line.trim_start().starts_with("fn evil()")),
799            "injected content must never appear as its own uncommented line:\n{file}"
800        );
801
802        let header = file
803            .lines()
804            .find(|line| line.contains(HEADER_SENTINEL))
805            .expect("assembled file must carry a header line");
806        let fields = parse_header_fields(header).expect("header line must parse");
807        let parsed_engine = fields
808            .iter()
809            .find(|(key, _)| *key == "engine")
810            .map(|(_, value)| value.as_str());
811
812        assert_eq!(parsed_engine, Some(engine), "header: {header:?}");
813    }
814
815    /// Tokens with no `=` must be skipped, or Python's `# noqa: E501`
816    /// suffix would be read as fields. Asserted on the real emitted line
817    /// rather than a hand-written one.
818    #[test]
819    fn parse_header_fields_skips_the_python_noqa_suffix() {
820        let backend =
821            crate::get_backend("python-psycopg3", "postgresql").expect("python-psycopg3 should support postgresql");
822        let line = header_line(
823            backend.as_ref(),
824            "0.15.0",
825            "postgresql",
826            "sch1:0123456789abcdef",
827            "q1:fedcba9876543210",
828        );
829
830        let keys: Vec<&str> = parse_header_fields(&line)
831            .expect("header line must parse")
832            .into_iter()
833            .map(|(key, _)| key)
834            .collect();
835
836        assert_eq!(keys, vec!["v", "backend", "engine", "schema", "queries"]);
837    }
838
839    #[test]
840    fn parse_header_fields_returns_none_without_the_sentinel() {
841        assert!(parse_header_fields("// just a comment v=1.2.3").is_none());
842    }
843
844    /// Unknown keys are returned, not dropped: the header format and any
845    /// given reader have to be able to grow independently.
846    #[test]
847    fn parse_header_fields_preserves_unknown_keys() {
848        let fields =
849            parse_header_fields("// scythe:provenance v=0.15.0 future_field=xyz").expect("header line must parse");
850        assert_eq!(
851            fields,
852            vec![("v", "0.15.0".to_string()), ("future_field", "xyz".to_string())]
853        );
854    }
855
856    /// Documents why the verifier's sanitized-vs-sanitized SC-PRV04
857    /// comparison cannot be exercised end to end with a value that actually
858    /// differs pre/post sanitization: `get_backend` rejects any `engine`
859    /// whose `normalize_engine()` output is not an exact-string match for
860    /// one of the backend's `supported_engines()`, and every recognized
861    /// alias is a clean literal containing neither `\n` nor `\r`. Any engine
862    /// value containing one fails `get_backend` before verification ever
863    /// reaches the comparison — so the sanitizing comparison closes a latent
864    /// bug, not a currently reachable one. It is still correct:
865    /// [`header_line`] accepts an arbitrary `&str` with no such gate, so the
866    /// comparison must not depend on `get_backend` policing its input for it.
867    #[test]
868    fn sanitize_field_is_a_no_op_for_every_alias_get_backend_accepts() {
869        for alias in [
870            "postgresql",
871            "postgres",
872            "pg",
873            "cockroachdb",
874            "crdb",
875            "mysql",
876            "mariadb",
877            "sqlite",
878            "sqlite3",
879            "duckdb",
880            "mssql",
881            "sqlserver",
882            "tsql",
883            "oracle",
884            "snowflake",
885            "redshift",
886        ] {
887            assert_eq!(sanitize_field(alias).as_ref(), alias);
888        }
889    }
890
891    #[test]
892    fn header_line_embeds_the_caller_supplied_version_not_this_crates_own() {
893        let backend = crate::get_backend("rust-sqlx", "postgresql").expect("rust-sqlx should support postgresql");
894        let line = header_line(
895            backend.as_ref(),
896            "9.9.9-caller",
897            "postgresql",
898            "sch1:0123456789abcdef",
899            "q1:fedcba9876543210",
900        );
901
902        assert_eq!(
903            line,
904            "// scythe:provenance v=9.9.9-caller backend=rust-sqlx engine=postgresql schema=sch1:0123456789abcdef \
905             queries=q1:fedcba9876543210\n"
906        );
907    }
908
909    #[test]
910    fn assemble_file_omits_separator_when_preamble_is_empty() {
911        assert_eq!(assemble_file("", "// header\n", "body\n"), "// header\nbody\n");
912    }
913
914    #[test]
915    fn assemble_file_inserts_separator_when_preamble_is_non_empty() {
916        assert_eq!(
917            assemble_file("<?php\n", "// header\n", "declare(strict_types=1);\n"),
918            "<?php\n// header\n\ndeclare(strict_types=1);\n"
919        );
920    }
921
922    // -----------------------------------------------------------------------
923    // `header_line_with_options` (GH #155)
924    // -----------------------------------------------------------------------
925
926    /// The refactor behind `header_line_with_options` (splitting `header_line`
927    /// into a shared `build_header_line`) must not move a single byte of the
928    /// five-field header every existing caller -- including the
929    /// generator-produced regression suites this crate cannot hand-edit --
930    /// still constructs. Pinned as a literal, not compared against a second
931    /// call to `header_line`, so a change to `build_header_line` that moved
932    /// *both* callers together the same wrong way would still be caught.
933    #[test]
934    fn header_line_is_byte_identical_to_the_pre_options_five_field_form() {
935        let backend = crate::get_backend("rust-sqlx", "postgresql").expect("rust-sqlx should support postgresql");
936        let line = header_line(
937            backend.as_ref(),
938            "0.15.0",
939            "postgresql",
940            "sch1:0123456789abcdef",
941            "q1:fedcba9876543210",
942        );
943
944        assert_eq!(
945            line,
946            "// scythe:provenance v=0.15.0 backend=rust-sqlx engine=postgresql schema=sch1:0123456789abcdef \
947             queries=q1:fedcba9876543210\n"
948        );
949    }
950
951    #[test]
952    fn header_line_with_options_appends_the_sixth_field_before_the_suffix() {
953        let backend = crate::get_backend("rust-sqlx", "postgresql").expect("rust-sqlx should support postgresql");
954        let line = header_line_with_options(
955            backend.as_ref(),
956            "0.15.0",
957            "postgresql",
958            "sch1:0123456789abcdef",
959            "q1:fedcba9876543210",
960            "opt1:0011223344556677",
961        );
962
963        assert_eq!(
964            line,
965            "// scythe:provenance v=0.15.0 backend=rust-sqlx engine=postgresql schema=sch1:0123456789abcdef \
966             queries=q1:fedcba9876543210 options=opt1:0011223344556677\n"
967        );
968    }
969
970    /// The suffix (Python's `# noqa: E501`) must stay the last thing on the
971    /// line, after `options=`, exactly as it already stays after `queries=`.
972    #[test]
973    fn header_line_with_options_keeps_the_python_suffix_last() {
974        let backend =
975            crate::get_backend("python-psycopg3", "postgresql").expect("python-psycopg3 should support postgresql");
976        let line = header_line_with_options(
977            backend.as_ref(),
978            "0.15.0",
979            "postgresql",
980            "sch1:0123456789abcdef",
981            "q1:fedcba9876543210",
982            "opt1:0011223344556677",
983        );
984
985        assert!(
986            line.ends_with("options=opt1:0011223344556677  # noqa: E501\n"),
987            "got: {line:?}"
988        );
989    }
990
991    #[test]
992    fn header_line_with_options_round_trips_through_parse_header_fields() {
993        let backend = crate::get_backend("rust-sqlx", "postgresql").expect("rust-sqlx should support postgresql");
994        let line = header_line_with_options(
995            backend.as_ref(),
996            "0.15.0",
997            "postgresql",
998            "sch1:0123456789abcdef",
999            "q1:fedcba9876543210",
1000            "opt1:0011223344556677",
1001        );
1002
1003        let fields = parse_header_fields(&line).expect("header line must parse");
1004        let options = fields
1005            .iter()
1006            .find(|(key, _)| *key == "options")
1007            .map(|(_, value)| value.as_str());
1008
1009        assert_eq!(options, Some("opt1:0011223344556677"));
1010    }
1011
1012    // -----------------------------------------------------------------------
1013    // `options_fingerprint` (GH #155)
1014    // -----------------------------------------------------------------------
1015
1016    #[test]
1017    fn options_fingerprint_has_the_opt1_tag_shape() {
1018        let fp = options_fingerprint([("row_type", "pydantic")], None);
1019        assert!(fp.starts_with("opt1:"), "got: {fp:?}");
1020        assert_eq!(
1021            fp.len(),
1022            "opt1:".len() + 16,
1023            "expected a 16-hex-digit digest after the tag, got: {fp:?}"
1024        );
1025    }
1026
1027    /// The whole reason `options_fingerprint` sorts internally: a caller's
1028    /// `HashMap` iterates in an order this function must not be sensitive
1029    /// to, since two runs of `scythe generate` against the identical config
1030    /// can iterate that map differently.
1031    #[test]
1032    fn options_fingerprint_is_independent_of_input_order() {
1033        let forward = options_fingerprint([("derive", "Debug"), ("row_type", "pydantic"), ("serde", "true")], None);
1034        let backward = options_fingerprint([("serde", "true"), ("row_type", "pydantic"), ("derive", "Debug")], None);
1035
1036        assert_eq!(forward, backward);
1037    }
1038
1039    /// Calling the function twice with the identical input must produce the
1040    /// identical output -- the property that makes it safe to call once when
1041    /// writing the header and again, independently, when verifying it.
1042    #[test]
1043    fn options_fingerprint_is_deterministic_across_repeated_calls() {
1044        let a = options_fingerprint(
1045            [("row_type", "pydantic")],
1046            Some("[naming]\nstruct_case = \"PascalCase\"\n"),
1047        );
1048        let b = options_fingerprint(
1049            [("row_type", "pydantic")],
1050            Some("[naming]\nstruct_case = \"PascalCase\"\n"),
1051        );
1052
1053        assert_eq!(a, b);
1054    }
1055
1056    #[test]
1057    fn options_fingerprint_changes_when_an_option_value_changes() {
1058        let pydantic = options_fingerprint([("row_type", "pydantic")], None);
1059        let dataclass = options_fingerprint([("row_type", "dataclass")], None);
1060
1061        assert_ne!(pydantic, dataclass, "a changed option value must move the fingerprint");
1062    }
1063
1064    #[test]
1065    fn options_fingerprint_changes_when_an_option_is_added_or_removed() {
1066        let one_option = options_fingerprint([("row_type", "pydantic")], None);
1067        let two_options = options_fingerprint([("row_type", "pydantic"), ("serde", "true")], None);
1068
1069        assert_ne!(one_option, two_options, "an added option must move the fingerprint");
1070    }
1071
1072    #[test]
1073    fn options_fingerprint_changes_when_the_manifest_overlay_gains_content() {
1074        let no_overlay = options_fingerprint([("row_type", "pydantic")], None);
1075        let with_overlay = options_fingerprint(
1076            [("row_type", "pydantic")],
1077            Some("[naming]\nstruct_case = \"snake_case\"\n"),
1078        );
1079
1080        assert_ne!(
1081            no_overlay, with_overlay,
1082            "configuring a manifest overlay must move the fingerprint even when `options` is unchanged"
1083        );
1084    }
1085
1086    /// The overlay is fingerprinted by *content*, not merely by "present or
1087    /// absent" -- editing the overlay file (e.g. changing a scalar mapping)
1088    /// must be visible even though `target.options` never changes and the
1089    /// overlay is configured both before and after the edit.
1090    #[test]
1091    fn options_fingerprint_changes_when_the_manifest_overlay_content_changes() {
1092        let before = options_fingerprint(
1093            [("row_type", "pydantic")],
1094            Some("[naming]\nstruct_case = \"PascalCase\"\n"),
1095        );
1096        let after = options_fingerprint(
1097            [("row_type", "pydantic")],
1098            Some("[naming]\nstruct_case = \"snake_case\"\n"),
1099        );
1100
1101        assert_ne!(before, after);
1102    }
1103
1104    #[test]
1105    fn options_fingerprint_of_no_options_and_no_overlay_is_deterministic() {
1106        assert_eq!(
1107            options_fingerprint(std::iter::empty(), None),
1108            options_fingerprint(std::iter::empty(), None)
1109        );
1110    }
1111
1112    /// A key or value containing the canonical form's own delimiters
1113    /// (`\t`, `\n`) must not be able to forge what looks like a second
1114    /// `opt` or `manifest` line -- the same anti-forgery guarantee
1115    /// `sanitize_field` already gives every other provenance field.
1116    #[test]
1117    fn options_fingerprint_is_not_forgeable_by_a_value_containing_a_delimiter() {
1118        let real_two_options = options_fingerprint([("a", "1"), ("b", "2")], None);
1119        let forged_single_option = options_fingerprint([("a", "1\nopt\tb\t2")], None);
1120
1121        assert_ne!(
1122            real_two_options, forged_single_option,
1123            "a value containing a raw newline/tab must not be able to forge an extra `opt` line"
1124        );
1125    }
1126}