hecks 3.6.0 → 3.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/bluebook/meta_validator/syntax_boot/disk_cache.rb +28 -3
  3. data/lib/hecks/bluebook/meta_validator/verdict_cache/prebuilt.rb +44 -0
  4. data/lib/hecks/bluebook/meta_validator/verdict_cache.rb +3 -17
  5. data/lib/hecks/cache_dir.rb +10 -0
  6. data/lib/hecks/deploy/bluebook/deploy.bluebook +109 -0
  7. data/lib/hecks/deploy/oidc.json +10 -0
  8. data/lib/hecks/doors/usage_cache.rb +19 -9
  9. data/lib/hecks/fuzzing/coverage_campaign.rb +42 -17
  10. data/lib/hecks/fuzzing/failure_triage.rb +80 -0
  11. data/lib/hecks/fuzzing/form_census/chapter_forms.rb +38 -0
  12. data/lib/hecks/fuzzing/form_census/reference_forms.rb +49 -0
  13. data/lib/hecks/fuzzing/form_census.rb +28 -52
  14. data/lib/hecks/fuzzing/isolated_boot/postgres_scratch.rb +22 -7
  15. data/lib/hecks/fuzzing/isolated_boot/scratch_schema.rb +27 -0
  16. data/lib/hecks/fuzzing/isolated_boot.rb +6 -2
  17. data/lib/hecks/fuzzing/mutation/checks.rb +76 -0
  18. data/lib/hecks/fuzzing/mutation/finders.rb +136 -0
  19. data/lib/hecks/fuzzing/mutation/judge.rb +94 -0
  20. data/lib/hecks/fuzzing/mutation/observation.rb +38 -0
  21. data/lib/hecks/fuzzing/mutation/operators.rb +52 -0
  22. data/lib/hecks/fuzzing/mutation/report.rb +78 -0
  23. data/lib/hecks/fuzzing/mutation/run.rb +99 -0
  24. data/lib/hecks/fuzzing/mutation/selection.rb +32 -0
  25. data/lib/hecks/fuzzing/mutation/site.rb +21 -0
  26. data/lib/hecks/fuzzing/mutation/source_map.rb +59 -0
  27. data/lib/hecks/fuzzing/mutation.rb +12 -0
  28. data/lib/hecks/fuzzing/properties/catalog.rb +10 -59
  29. data/lib/hecks/fuzzing/properties/guaranteed_by_construction.rb +63 -0
  30. data/lib/hecks/fuzzing/properties/read_model_names.rb +50 -0
  31. data/lib/hecks/fuzzing/properties/read_models.rb +138 -0
  32. data/lib/hecks/fuzzing/properties/role_checks.rb +50 -0
  33. data/lib/hecks/fuzzing/properties.rb +7 -0
  34. data/lib/hecks/fuzzing/replay/observation.rb +1 -1
  35. data/lib/hecks/fuzzing/replay/role_check.rb +49 -0
  36. data/lib/hecks/fuzzing/replay/session.rb +22 -6
  37. data/lib/hecks/fuzzing/replay.rb +1 -0
  38. data/lib/hecks/fuzzing/runtime_coverage.rb +70 -0
  39. data/lib/hecks/fuzzing/shrinker/value_simplification.rb +64 -0
  40. data/lib/hecks/fuzzing/shrinker.rb +3 -0
  41. data/lib/hecks/fuzzing.rb +3 -0
  42. data/lib/hecks/gate/stages.yml +1 -1
  43. data/lib/hecks/hecks/adapters/deploy_toolchain/answers.rb +2 -1
  44. data/lib/hecks/hecks/adapters/github_rulesets.rb +7 -5
  45. data/lib/hecks/hecks/adapters/process_pool.rb +26 -4
  46. data/lib/hecks/hecks/custodian.behaviors +6 -0
  47. data/lib/hecks/hecks/custodian.bluebook +37 -0
  48. data/lib/hecks/hecks/hecks.hecksagon +6 -0
  49. data/lib/hecks/language/bluebook/vocabulary.bluebook +3 -0
  50. data/lib/hecks/projections/deploy/box/roll_scripts.rb +6 -4
  51. data/lib/hecks/projections/deploy/box/settings/patterns.rb +4 -0
  52. data/lib/hecks/projections/deploy/box/settings/types.rb +7 -3
  53. data/lib/hecks/projections/deploy/box/settings.rb +18 -0
  54. data/lib/hecks/projections/deploy/box/shared_database.rb +69 -0
  55. data/lib/hecks/projections/deploy/box/templates/Makefile.tmpl +6 -7
  56. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +1 -1
  57. data/lib/hecks/projections/deploy/box/templates/provision-database.sh.tmpl +97 -0
  58. data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +4 -2
  59. data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +2 -1
  60. data/lib/hecks/projections/deploy/box.rb +14 -2
  61. data/lib/hecks/projections/deploy/shared_database_instance/templates/Makefile.tmpl +11 -0
  62. data/lib/hecks/projections/deploy/shared_database_instance/templates/README.md.tmpl +25 -0
  63. data/lib/hecks/projections/deploy/shared_database_instance/templates/rds.yaml.tmpl +180 -0
  64. data/lib/hecks/projections/deploy/shared_database_instance.rb +106 -0
  65. data/lib/hecks/projections/deploy/vercel/settings.rb +83 -0
  66. data/lib/hecks/projections/deploy/vercel.rb +134 -0
  67. data/lib/hecks/projections/site/site_host/templates.rb +12 -4
  68. data/lib/hecks/projections.rb +2 -0
  69. data/lib/hecks/quality_control/adapters/github_checks.rb +9 -2
  70. data/lib/hecks/quality_control/cli/qa_sweep/dials.rb +1 -0
  71. data/lib/hecks/quality_control/cli/qa_sweep/seed_loop.rb +10 -2
  72. data/lib/hecks/rust_build/native_build.rb +29 -1
  73. data/lib/hecks/rust_build/wasm.rb +16 -3
  74. data/lib/hecks/tools/deploy_recipe/generation.rb +59 -0
  75. data/lib/hecks/tools/deploy_recipe/targets.rb +56 -2
  76. data/lib/hecks/tools/deploy_recipe/worlds.rb +2 -1
  77. data/lib/hecks/tools/deploy_recipe.rb +20 -10
  78. data/lib/hecks/tools/fuzz_sweep/options.rb +21 -10
  79. data/lib/hecks/tools/fuzz_sweep/reporting.rb +25 -3
  80. data/lib/hecks/tools/fuzz_sweep.rb +26 -9
  81. data/lib/hecks/tools/lanes/files.rb +2 -4
  82. data/lib/hecks/tools/lanes/live.rb +3 -4
  83. data/lib/hecks/tools/lanes/ruleset.rb +23 -1
  84. data/lib/hecks/tools/lanes.rb +12 -1
  85. data/lib/hecks/tools/mutation_run.rb +73 -0
  86. data/lib/hecks/tools.rb +1 -0
  87. data/lib/hecks/version.rb +1 -1
  88. data/lib/hecks/vocabulary.rb +2 -0
  89. data/prebuilt/945481eefbdb584649d45f256307053558966a868b9c38dee7ee88f8169610f8.marshal +0 -0
  90. data/prebuilt/a0818e2de67364dc1ab22868b6cbf869c62da6f8d9b924c778fa5cce2e76ee94.marshal +0 -0
  91. data/prebuilt/verdicts-be73f0df9eee0abe2f4aadc968321c4b919d19d424a3ccfe1777a71dabf7d9e7.json +1 -0
  92. data/qa/settings.yml +1 -0
  93. data/rust/host/Cargo.toml +2 -2
  94. data/rust/host/HECKS_RELEASE +1 -1
  95. data/rust/host/src/api.rs +28 -5
  96. data/rust/host/src/auth.rs +11 -1
  97. data/rust/host/src/boundary_fuzz/api.rs +260 -0
  98. data/rust/host/src/boundary_fuzz/auth.rs +119 -0
  99. data/rust/host/src/boundary_fuzz/expr_json.rs +158 -0
  100. data/rust/host/src/boundary_fuzz/ir.rs +78 -0
  101. data/rust/host/src/boundary_fuzz/server.rs +227 -0
  102. data/rust/host/src/boundary_fuzz/web.rs +125 -0
  103. data/rust/host/src/fuzz_support.rs +165 -0
  104. data/rust/host/src/ir.rs +4 -0
  105. data/rust/host/src/main.rs +9 -4
  106. data/rust/host/src/secrets.rs +44 -0
  107. data/rust/host/src/server.rs +6 -1
  108. data/rust/host/src/web.rs +12 -2
  109. data/rust/parser/fuzz/.gitignore +4 -0
  110. data/rust/parser/fuzz/Cargo.lock +89 -0
  111. data/rust/parser/fuzz/Cargo.toml +27 -0
  112. data/rust/parser/fuzz/README.md +19 -0
  113. data/rust/parser/fuzz/fuzz_targets/chapter.rs +50 -0
  114. data/rust/parser/fuzz/fuzz_targets/resolve.rs +35 -0
  115. data/rust/parser/src/parse/command.rs +14 -2
  116. data/rust/parser/src/parse/mod.rs +25 -0
  117. data/rust/parser/src/ruby_value.rs +95 -6
  118. data/rust/src/kernel/json.rs +34 -3
  119. metadata +50 -1
@@ -75,10 +75,54 @@ pub fn extract_field(secret_json: &str, field: &str) -> Result<String, String> {
75
75
  .ok_or_else(|| format!("secret's SecretString has no string {field:?} field"))
76
76
  }
77
77
 
78
+ /// The `postgres://` URL for a database secret.
79
+ ///
80
+ /// The user is the secret's `username`, which is `postgres` for RDS's managed master secret and a
81
+ /// client's own role for a database shared with other clients; a secret without one is read as
82
+ /// `postgres`. The port is the secret's `port` when it has one, else 5432. The password goes in
83
+ /// literal, as before: the URL parser downstream never percent-decodes it, so a client's generated
84
+ /// password is letters and digits only.
85
+ pub fn database_url(secret_json: &str, host: &str, database: &str) -> Result<String, String> {
86
+ let password = extract_field(secret_json, "password")?;
87
+ let user = extract_field(secret_json, "username").unwrap_or_else(|_| "postgres".to_string());
88
+ let port = serde_json::from_str::<serde_json::Value>(secret_json)
89
+ .ok()
90
+ .and_then(|v| v.get("port").and_then(|p| p.as_u64().or_else(|| p.as_str().and_then(|s| s.parse().ok()))))
91
+ .unwrap_or(5432);
92
+ Ok(format!("postgres://{user}:{password}@{host}:{port}/{database}"))
93
+ }
94
+
78
95
  #[cfg(test)]
79
96
  mod tests {
80
97
  use super::*;
81
98
 
99
+ #[test]
100
+ fn a_master_secret_logs_in_as_postgres_on_the_default_port() {
101
+ let json = r#"{"username":"postgres","password":"abc123"}"#;
102
+ assert_eq!(database_url(json, "db.example", "app").unwrap(), "postgres://postgres:abc123@db.example:5432/app");
103
+ }
104
+
105
+ #[test]
106
+ fn a_clients_own_secret_logs_in_as_its_role() {
107
+ let json = r#"{"username":"acme","password":"s3cr3t","host":"db.example","port":5433,"dbname":"acme"}"#;
108
+ assert_eq!(database_url(json, "db.example", "acme").unwrap(), "postgres://acme:s3cr3t@db.example:5433/acme");
109
+ }
110
+
111
+ #[test]
112
+ fn a_secret_with_no_username_is_read_as_postgres() {
113
+ assert_eq!(database_url(r#"{"password":"pw"}"#, "h", "d").unwrap(), "postgres://postgres:pw@h:5432/d");
114
+ }
115
+
116
+ #[test]
117
+ fn a_port_written_as_text_is_read() {
118
+ assert_eq!(database_url(r#"{"password":"pw","port":"6000"}"#, "h", "d").unwrap(), "postgres://postgres:pw@h:6000/d");
119
+ }
120
+
121
+ #[test]
122
+ fn a_secret_with_no_password_is_refused() {
123
+ assert!(database_url(r#"{"username":"acme"}"#, "h", "d").is_err());
124
+ }
125
+
82
126
  #[test]
83
127
  fn extracts_the_named_field_from_the_generated_secret_shape() {
84
128
  assert_eq!(
@@ -276,7 +276,8 @@ fn value_to_response(value: Value) -> Response {
276
276
  let Some(status_code) = value.get("statusCode").and_then(|v| v.as_u64()) else {
277
277
  return (StatusCode::OK, axum::Json(value)).into_response();
278
278
  };
279
- let status = StatusCode::from_u16(status_code as u16).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
279
+ // `try_from`, not `as u16`: 65736 would wrap to 200 and turn a handler's garbage into a success.
280
+ let status = u16::try_from(status_code).ok().and_then(|code| StatusCode::from_u16(code).ok()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
280
281
 
281
282
  let mut headers = HeaderMap::new();
282
283
  if let Some(header_obj) = value.get("headers").and_then(|h| h.as_object()) {
@@ -665,3 +666,7 @@ mod tests {
665
666
  assert_eq!(version_body("e", "h", Some(""))["build"], env!("CARGO_PKG_VERSION"));
666
667
  }
667
668
  }
669
+
670
+ #[cfg(test)]
671
+ #[path = "boundary_fuzz/server.rs"]
672
+ mod boundary_fuzz;
data/rust/host/src/web.rs CHANGED
@@ -1607,8 +1607,10 @@ fn percent_decode_impl(s: &str, plus_as_space: bool) -> String {
1607
1607
  i += 1;
1608
1608
  }
1609
1609
  b'%' if i + 2 < bytes.len() => {
1610
- if let Ok(byte) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
1611
- out.push(byte);
1610
+ // Byte-wise, not `s[i + 1..i + 3]`: that slice panics when it lands inside a
1611
+ // multi-byte character (`%a🍕`), and `from_str_radix` would read a `+` sign as a digit.
1612
+ if let (Some(high), Some(low)) = (hex_digit(bytes[i + 1]), hex_digit(bytes[i + 2])) {
1613
+ out.push(high << 4 | low);
1612
1614
  i += 3;
1613
1615
  } else {
1614
1616
  out.push(bytes[i]);
@@ -1624,6 +1626,10 @@ fn percent_decode_impl(s: &str, plus_as_space: bool) -> String {
1624
1626
  String::from_utf8_lossy(&out).into_owned()
1625
1627
  }
1626
1628
 
1629
+ fn hex_digit(byte: u8) -> Option<u8> {
1630
+ (byte as char).to_digit(16).map(|digit| digit as u8)
1631
+ }
1632
+
1627
1633
  const B64: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1628
1634
 
1629
1635
  // pub(crate) so server.rs's own Function URL response handling can
@@ -2982,3 +2988,7 @@ mod tests {
2982
2988
  }
2983
2989
 
2984
2990
  }
2991
+
2992
+ #[cfg(test)]
2993
+ #[path = "boundary_fuzz/web.rs"]
2994
+ mod boundary_fuzz;
@@ -0,0 +1,4 @@
1
+ target/
2
+ corpus/
3
+ artifacts/
4
+ coverage/
@@ -0,0 +1,89 @@
1
+ # This file is automatically @generated by Cargo.
2
+ # It is not intended for manual editing.
3
+ version = 4
4
+
5
+ [[package]]
6
+ name = "arbitrary"
7
+ version = "1.4.2"
8
+ source = "registry+https://github.com/rust-lang/crates.io-index"
9
+ checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
10
+
11
+ [[package]]
12
+ name = "cc"
13
+ version = "1.6.0"
14
+ source = "registry+https://github.com/rust-lang/crates.io-index"
15
+ checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630"
16
+ dependencies = [
17
+ "find-msvc-tools",
18
+ "jobserver",
19
+ "libc",
20
+ "shlex",
21
+ ]
22
+
23
+ [[package]]
24
+ name = "cfg-if"
25
+ version = "1.0.5"
26
+ source = "registry+https://github.com/rust-lang/crates.io-index"
27
+ checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
28
+
29
+ [[package]]
30
+ name = "find-msvc-tools"
31
+ version = "0.1.14"
32
+ source = "registry+https://github.com/rust-lang/crates.io-index"
33
+ checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
34
+
35
+ [[package]]
36
+ name = "getrandom"
37
+ version = "0.4.3"
38
+ source = "registry+https://github.com/rust-lang/crates.io-index"
39
+ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
40
+ dependencies = [
41
+ "cfg-if",
42
+ "libc",
43
+ "r-efi",
44
+ ]
45
+
46
+ [[package]]
47
+ name = "hecks_parse_fuzz"
48
+ version = "0.0.0"
49
+ dependencies = [
50
+ "libfuzzer-sys",
51
+ ]
52
+
53
+ [[package]]
54
+ name = "jobserver"
55
+ version = "0.1.35"
56
+ source = "registry+https://github.com/rust-lang/crates.io-index"
57
+ checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
58
+ dependencies = [
59
+ "getrandom",
60
+ "libc",
61
+ ]
62
+
63
+ [[package]]
64
+ name = "libc"
65
+ version = "0.2.190"
66
+ source = "registry+https://github.com/rust-lang/crates.io-index"
67
+ checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
68
+
69
+ [[package]]
70
+ name = "libfuzzer-sys"
71
+ version = "0.4.13"
72
+ source = "registry+https://github.com/rust-lang/crates.io-index"
73
+ checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2"
74
+ dependencies = [
75
+ "arbitrary",
76
+ "cc",
77
+ ]
78
+
79
+ [[package]]
80
+ name = "r-efi"
81
+ version = "6.0.0"
82
+ source = "registry+https://github.com/rust-lang/crates.io-index"
83
+ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
84
+
85
+ [[package]]
86
+ name = "shlex"
87
+ version = "2.0.1"
88
+ source = "registry+https://github.com/rust-lang/crates.io-index"
89
+ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
@@ -0,0 +1,27 @@
1
+ [package]
2
+ name = "hecks_parse_fuzz"
3
+ version = "0.0.0"
4
+ edition = "2021"
5
+ publish = false
6
+
7
+ # Own workspace root, so neither `rust/parser` nor `rust` adopts this crate and its dependency.
8
+ [workspace]
9
+
10
+ # The one dependency in the parser tree, and it lives only here: coverage-guided fuzzing needs
11
+ # libFuzzer's instrumentation, and nothing ships from this crate (ADR 0012).
12
+ [dependencies]
13
+ libfuzzer-sys = "0.4"
14
+
15
+ [[bin]]
16
+ name = "chapter"
17
+ path = "fuzz_targets/chapter.rs"
18
+ test = false
19
+ doc = false
20
+ bench = false
21
+
22
+ [[bin]]
23
+ name = "resolve"
24
+ path = "fuzz_targets/resolve.rs"
25
+ test = false
26
+ doc = false
27
+ bench = false
@@ -0,0 +1,19 @@
1
+ # Coverage-guided fuzzing for hecks-parse
2
+
3
+ Opt-in. The parser stays dependency-free (ADR 0012); `libfuzzer-sys` lives only in this crate,
4
+ which is its own workspace and is never built by `cargo build` or `cargo test` in `rust/parser`.
5
+
6
+ ```sh
7
+ rustup toolchain install nightly
8
+ cargo install cargo-fuzz
9
+ cd rust/parser
10
+ cargo +nightly fuzz run chapter -- -max_total_time=60 # bluebook parse + determinism
11
+ cargo +nightly fuzz run resolve -- -max_total_time=60 # hecksagon `attaches` resolution
12
+ ```
13
+
14
+ Seed a run from the fixtures: `cargo +nightly fuzz run chapter ../tests/fixtures`. A crash lands
15
+ in `fuzz/artifacts/`; copy it to a `.bluebook` and run `hecks-parse chapter --chapter Fuzzed
16
+ <file>` to reproduce it.
17
+
18
+ The seeded harness in `tests/fuzz.rs` is what CI runs; this directory is for longer
19
+ coverage-guided hunts.
@@ -0,0 +1,50 @@
1
+ //! Coverage-guided target for `parse::chapter::parse_chapter`.
2
+ //!
3
+ //! The parser is a binary crate, so this target includes its modules by path and declares them at
4
+ //! its own crate root, which is where their `crate::` references resolve.
5
+ //!
6
+ //! Run from `rust/parser` with a nightly toolchain: `cargo +nightly fuzz run chapter`.
7
+ #![no_main]
8
+ #![allow(dead_code)]
9
+
10
+ #[path = "../../src/build/mod.rs"]
11
+ mod build;
12
+ #[path = "../../src/canonical.rs"]
13
+ mod canonical;
14
+ #[path = "../../src/diag.rs"]
15
+ mod diag;
16
+ #[path = "../../src/emit.rs"]
17
+ mod emit;
18
+ #[path = "../../src/expr/mod.rs"]
19
+ mod expr;
20
+ #[path = "../../src/ir.rs"]
21
+ mod ir;
22
+ #[path = "../../src/keywords.rs"]
23
+ mod keywords;
24
+ #[path = "../../src/lex.rs"]
25
+ mod lex;
26
+ #[path = "../../src/parse/mod.rs"]
27
+ mod parse;
28
+ #[path = "../../src/ruby_value.rs"]
29
+ mod ruby_value;
30
+
31
+ use libfuzzer_sys::fuzz_target;
32
+
33
+ fuzz_target!(|data: &[u8]| {
34
+ // Invalid UTF-8 never reaches the parser: the binary rejects it when it reads the file.
35
+ let Ok(source) = std::str::from_utf8(data) else {
36
+ return;
37
+ };
38
+ let files = [("fuzz.bluebook".to_string(), source.to_string())];
39
+ // A refusal is a normal answer; only a panic or a hang is a finding.
40
+ if let Ok(bluebook) = parse::chapter::parse_chapter("Fuzzed", &files) {
41
+ let first = emit::write(&emit::bluebook_json(&bluebook));
42
+ let again = parse::chapter::parse_chapter("Fuzzed", &files)
43
+ .map(|b| emit::write(&emit::bluebook_json(&b)));
44
+ assert_eq!(
45
+ Ok(first),
46
+ again.map_err(|_| ()),
47
+ "parse is not deterministic"
48
+ );
49
+ }
50
+ });
@@ -0,0 +1,35 @@
1
+ //! Coverage-guided target for `parse::chapter::resolve_hecksagon_dependencies`.
2
+ //!
3
+ //! Run from `rust/parser` with a nightly toolchain: `cargo +nightly fuzz run resolve`.
4
+ #![no_main]
5
+ #![allow(dead_code)]
6
+
7
+ #[path = "../../src/build/mod.rs"]
8
+ mod build;
9
+ #[path = "../../src/canonical.rs"]
10
+ mod canonical;
11
+ #[path = "../../src/diag.rs"]
12
+ mod diag;
13
+ #[path = "../../src/emit.rs"]
14
+ mod emit;
15
+ #[path = "../../src/expr/mod.rs"]
16
+ mod expr;
17
+ #[path = "../../src/ir.rs"]
18
+ mod ir;
19
+ #[path = "../../src/keywords.rs"]
20
+ mod keywords;
21
+ #[path = "../../src/lex.rs"]
22
+ mod lex;
23
+ #[path = "../../src/parse/mod.rs"]
24
+ mod parse;
25
+ #[path = "../../src/ruby_value.rs"]
26
+ mod ruby_value;
27
+
28
+ use libfuzzer_sys::fuzz_target;
29
+
30
+ fuzz_target!(|data: &[u8]| {
31
+ let Ok(source) = std::str::from_utf8(data) else {
32
+ return;
33
+ };
34
+ let _ = parse::chapter::resolve_hecksagon_dependencies("Fuzzed", "fuzz.hecksagon", source);
35
+ });
@@ -171,10 +171,22 @@ pub fn parse_body(
171
171
 
172
172
  match gated.row.word {
173
173
  "role" => {
174
- command.role = Some(super::positional_text(file, line, "role", &gated.args, 1)?)
174
+ command.role = Some(super::positional_literal_text(
175
+ file,
176
+ line,
177
+ "role",
178
+ &gated.args,
179
+ 1,
180
+ )?)
175
181
  }
176
182
  "goal" => {
177
- command.goal = Some(super::positional_text(file, line, "goal", &gated.args, 1)?)
183
+ command.goal = Some(super::positional_literal_text(
184
+ file,
185
+ line,
186
+ "goal",
187
+ &gated.args,
188
+ 1,
189
+ )?)
178
190
  }
179
191
  // A synthesized inline `one_of(...)` closed set is dropped; `CommandBuilder#build`
180
192
  // ignores it.
@@ -743,6 +743,31 @@ pub(crate) fn positional_text(
743
743
  })
744
744
  }
745
745
 
746
+ /// Like `positional_text`, but the argument must be a literal. Ruby evaluates what follows
747
+ /// `role` or `goal`, so a bare word or a call there (`role sets :name`) is an expression this
748
+ /// parser cannot evaluate, and reading its source text as the value would invent one.
749
+ pub(crate) fn positional_literal_text(
750
+ file: &str,
751
+ line: usize,
752
+ word: &str,
753
+ args: &ArgumentGateResult,
754
+ at: usize,
755
+ ) -> ParseResult<String> {
756
+ let raw = positional_raw(file, line, word, args, at)?;
757
+ match ruby_value::read(raw) {
758
+ ruby_value::Value::Bare(_) => Err(Diagnostic::new(
759
+ file,
760
+ line,
761
+ format!(
762
+ "'{word}'s positional argument {at} ('{}') is not a literal",
763
+ raw.trim()
764
+ ),
765
+ )),
766
+ ruby_value::Value::Str(s) => Ok(s),
767
+ literal => Ok(ruby_value::to_s(&literal)),
768
+ }
769
+ }
770
+
746
771
  /// A required symbol positional without its colon (`attribute :name`), bare or quoted (`:"a.b"`).
747
772
  /// The quoted form spells dotted paths, e.g. `correlates_by :"reference.value"`.
748
773
  pub(crate) fn positional_symbol(
@@ -192,21 +192,97 @@ fn unquote(raw: &str) -> String {
192
192
  unescape_double_quoted_inner(&raw[1..raw.len() - 1])
193
193
  }
194
194
 
195
+ /// Unescapes a Ruby double-quoted body: `\n`, `\t`, `\e` and the other single-letter escapes,
196
+ /// octal `\NNN`, `\xHH`, `\uHHHH` and `\u{H H ...}`, and `\<newline>` as nothing. Any other
197
+ /// escaped character stands for itself, as in Ruby. `\c`, `\C-` and `\M-` control and meta
198
+ /// escapes are left as the bare letter, since no bluebook spells them.
195
199
  fn unescape_double_quoted_inner(inner: &str) -> String {
196
200
  let mut out = String::with_capacity(inner.len());
197
- let mut chars = inner.chars();
201
+ let mut chars = inner.chars().peekable();
198
202
  while let Some(ch) = chars.next() {
199
- if ch == '\\' {
200
- if let Some(next) = chars.next() {
201
- out.push(next);
202
- }
203
- } else {
203
+ if ch != '\\' {
204
204
  out.push(ch);
205
+ continue;
206
+ }
207
+ let Some(next) = chars.next() else {
208
+ continue;
209
+ };
210
+ match next {
211
+ 'a' => out.push('\u{7}'),
212
+ 'b' => out.push('\u{8}'),
213
+ 'e' => out.push('\u{1b}'),
214
+ 'f' => out.push('\u{c}'),
215
+ 'n' => out.push('\n'),
216
+ 'r' => out.push('\r'),
217
+ 's' => out.push(' '),
218
+ 't' => out.push('\t'),
219
+ 'v' => out.push('\u{b}'),
220
+ '\n' => {}
221
+ '0'..='7' => out.push(read_octal_escape(next, &mut chars)),
222
+ 'x' => match read_hex_digits(&mut chars, 2) {
223
+ Some(code) => out.push(char::from_u32(code).unwrap_or('\u{fffd}')),
224
+ None => out.push('x'),
225
+ },
226
+ 'u' => read_unicode_escape(&mut chars, &mut out),
227
+ other => out.push(other),
205
228
  }
206
229
  }
207
230
  out
208
231
  }
209
232
 
233
+ /// Up to three octal digits starting with `first`, as one character.
234
+ fn read_octal_escape(first: char, chars: &mut std::iter::Peekable<std::str::Chars>) -> char {
235
+ let mut code = first.to_digit(8).unwrap_or(0);
236
+ for _ in 0..2 {
237
+ match chars.peek().and_then(|c| c.to_digit(8)) {
238
+ Some(digit) => {
239
+ code = code * 8 + digit;
240
+ chars.next();
241
+ }
242
+ None => break,
243
+ }
244
+ }
245
+ char::from_u32(code & 0xff).unwrap_or('\u{fffd}')
246
+ }
247
+
248
+ /// Between one and `max` hex digits, or `None` when the next character is not one.
249
+ fn read_hex_digits(chars: &mut std::iter::Peekable<std::str::Chars>, max: usize) -> Option<u32> {
250
+ let mut code: Option<u32> = None;
251
+ for _ in 0..max {
252
+ match chars.peek().and_then(|c| c.to_digit(16)) {
253
+ Some(digit) => {
254
+ code = Some(code.unwrap_or(0) * 16 + digit);
255
+ chars.next();
256
+ }
257
+ None => break,
258
+ }
259
+ }
260
+ code
261
+ }
262
+
263
+ /// `\uHHHH` (exactly four digits) or `\u{H H ...}` (one or more codepoints), after the `u`.
264
+ fn read_unicode_escape(chars: &mut std::iter::Peekable<std::str::Chars>, out: &mut String) {
265
+ if chars.peek() == Some(&'{') {
266
+ chars.next();
267
+ loop {
268
+ while chars.peek().is_some_and(|c| *c == ' ') {
269
+ chars.next();
270
+ }
271
+ match read_hex_digits(chars, 6) {
272
+ Some(code) => out.push(char::from_u32(code).unwrap_or('\u{fffd}')),
273
+ None => break,
274
+ }
275
+ }
276
+ if chars.peek() == Some(&'}') {
277
+ chars.next();
278
+ }
279
+ } else if let Some(code) = read_hex_digits(chars, 4) {
280
+ out.push(char::from_u32(code).unwrap_or('\u{fffd}'));
281
+ } else {
282
+ out.push('u');
283
+ }
284
+ }
285
+
210
286
  /// Scans adjacent quoted literals (either quote style, whitespace-separated) from index 0.
211
287
  /// Returns their unescaped concatenation and the chars consumed, or `None` if `raw` does not
212
288
  /// start with a closed literal. Callers compare `consumed` to the full length to tell a whole
@@ -408,6 +484,19 @@ mod tests {
408
484
  );
409
485
  }
410
486
 
487
+ #[test]
488
+ fn double_quoted_escapes_read_as_ruby_reads_them() {
489
+ assert_eq!(read(r#""a\tb\nc""#), Value::Str("a\tb\nc".to_string()));
490
+ assert_eq!(read(r#""\e\s\a\0""#), Value::Str("\u{1b} \u{7}\0".to_string()));
491
+ assert_eq!(
492
+ read(r#""\x41\101é\u{1F4A5 41}""#),
493
+ Value::Str("AA\u{e9}\u{1F4A5}A".to_string())
494
+ );
495
+ assert_eq!(read(r#""q\q\"\\""#), Value::Str("qq\"\\".to_string()));
496
+ assert_eq!(read("\"a\\\nb\""), Value::Str("ab".to_string()));
497
+ assert_eq!(read(r#""\xZ""#), Value::Str("xZ".to_string()));
498
+ }
499
+
411
500
  #[test]
412
501
  fn concatenates_three_adjacent_literals_mixing_quote_styles() {
413
502
  assert_eq!(read("\"a\" 'b' \"c\""), Value::Str("abc".to_string()));
@@ -346,7 +346,7 @@ impl Json {
346
346
  }
347
347
 
348
348
  pub fn parse(input: &str) -> Result<Json, String> {
349
- let mut parser = Parser { chars: input.chars().peekable() };
349
+ let mut parser = Parser { chars: input.chars().peekable(), depth: 0 };
350
350
  let value = parser.parse_value()?;
351
351
  parser.skip_ws();
352
352
  Ok(value)
@@ -611,8 +611,14 @@ fn write_escaped_string(s: &str, out: &mut String) {
611
611
  // Recursive descent over chars; the input is a valid `&str`, so no UTF-8 byte handling is needed.
612
612
  struct Parser<'a> {
613
613
  chars: std::iter::Peekable<std::str::Chars<'a>>,
614
+ depth: usize,
614
615
  }
615
616
 
617
+ /// How deeply arrays and objects may nest before `parse` refuses. Each level is a recursive call,
618
+ /// so an unbounded document (`[[[[...`) overflows the stack, which aborts the process rather than
619
+ /// refusing. 128 is `serde_json`'s own recursion limit, so the host and this parser refuse alike.
620
+ const MAX_DEPTH: usize = 128;
621
+
616
622
  impl<'a> Parser<'a> {
617
623
  fn skip_ws(&mut self) {
618
624
  while matches!(self.chars.peek(), Some(c) if c.is_whitespace()) {
@@ -651,7 +657,26 @@ impl<'a> Parser<'a> {
651
657
  }
652
658
  }
653
659
 
660
+ // Counts one level of nesting for the duration of `body`; refuses past `MAX_DEPTH`.
661
+ fn nested(&mut self, body: impl FnOnce(&mut Self) -> Result<Json, String>) -> Result<Json, String> {
662
+ if self.depth >= MAX_DEPTH {
663
+ return Err(format!("JSON nests deeper than {MAX_DEPTH} levels"));
664
+ }
665
+ self.depth += 1;
666
+ let result = body(self);
667
+ self.depth -= 1;
668
+ result
669
+ }
670
+
654
671
  fn parse_object(&mut self) -> Result<Json, String> {
672
+ self.nested(Self::parse_object_body)
673
+ }
674
+
675
+ fn parse_array(&mut self) -> Result<Json, String> {
676
+ self.nested(Self::parse_array_body)
677
+ }
678
+
679
+ fn parse_object_body(&mut self) -> Result<Json, String> {
655
680
  self.expect('{')?;
656
681
  let mut fields = Vec::new();
657
682
  self.skip_ws();
@@ -676,7 +701,7 @@ impl<'a> Parser<'a> {
676
701
  Ok(Json::Object(fields))
677
702
  }
678
703
 
679
- fn parse_array(&mut self) -> Result<Json, String> {
704
+ fn parse_array_body(&mut self) -> Result<Json, String> {
680
705
  self.expect('[')?;
681
706
  let mut items = Vec::new();
682
707
  self.skip_ws();
@@ -773,7 +798,13 @@ impl<'a> Parser<'a> {
773
798
  // A literal with a `.`/exponent is a genuine `Float`, and Ruby's `Float` is the same
774
799
  // `f64` Rust's is, so there is nothing to preserve there.
775
800
  let is_plain_integer = !s.contains('.') && !s.contains(['e', 'E']);
776
- let raw = if is_plain_integer && canonical_integer_digits(value) != s { Some(s) } else { None };
801
+ let keeps_source = is_plain_integer && canonical_integer_digits(value) != s;
802
+ // `1e999` parses to infinity with no source text kept, and `write` would print it as `inf`:
803
+ // not JSON, so refuse it. A plain integer keeps its digits, which `write` re-emits as-is.
804
+ if !value.is_finite() && !keeps_source {
805
+ return Err(format!("number {s:?} is out of range"));
806
+ }
807
+ let raw = keeps_source.then_some(s);
777
808
  Ok(Json::Num(value, raw))
778
809
  }
779
810
  }