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}