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.
- checksums.yaml +4 -4
- data/lib/hecks/bluebook/meta_validator/syntax_boot/disk_cache.rb +28 -3
- data/lib/hecks/bluebook/meta_validator/verdict_cache/prebuilt.rb +44 -0
- data/lib/hecks/bluebook/meta_validator/verdict_cache.rb +3 -17
- data/lib/hecks/cache_dir.rb +10 -0
- data/lib/hecks/deploy/bluebook/deploy.bluebook +109 -0
- data/lib/hecks/deploy/oidc.json +10 -0
- data/lib/hecks/doors/usage_cache.rb +19 -9
- data/lib/hecks/fuzzing/coverage_campaign.rb +42 -17
- data/lib/hecks/fuzzing/failure_triage.rb +80 -0
- data/lib/hecks/fuzzing/form_census/chapter_forms.rb +38 -0
- data/lib/hecks/fuzzing/form_census/reference_forms.rb +49 -0
- data/lib/hecks/fuzzing/form_census.rb +28 -52
- data/lib/hecks/fuzzing/isolated_boot/postgres_scratch.rb +22 -7
- data/lib/hecks/fuzzing/isolated_boot/scratch_schema.rb +27 -0
- data/lib/hecks/fuzzing/isolated_boot.rb +6 -2
- data/lib/hecks/fuzzing/mutation/checks.rb +76 -0
- data/lib/hecks/fuzzing/mutation/finders.rb +136 -0
- data/lib/hecks/fuzzing/mutation/judge.rb +94 -0
- data/lib/hecks/fuzzing/mutation/observation.rb +38 -0
- data/lib/hecks/fuzzing/mutation/operators.rb +52 -0
- data/lib/hecks/fuzzing/mutation/report.rb +78 -0
- data/lib/hecks/fuzzing/mutation/run.rb +99 -0
- data/lib/hecks/fuzzing/mutation/selection.rb +32 -0
- data/lib/hecks/fuzzing/mutation/site.rb +21 -0
- data/lib/hecks/fuzzing/mutation/source_map.rb +59 -0
- data/lib/hecks/fuzzing/mutation.rb +12 -0
- data/lib/hecks/fuzzing/properties/catalog.rb +10 -59
- data/lib/hecks/fuzzing/properties/guaranteed_by_construction.rb +63 -0
- data/lib/hecks/fuzzing/properties/read_model_names.rb +50 -0
- data/lib/hecks/fuzzing/properties/read_models.rb +138 -0
- data/lib/hecks/fuzzing/properties/role_checks.rb +50 -0
- data/lib/hecks/fuzzing/properties.rb +7 -0
- data/lib/hecks/fuzzing/replay/observation.rb +1 -1
- data/lib/hecks/fuzzing/replay/role_check.rb +49 -0
- data/lib/hecks/fuzzing/replay/session.rb +22 -6
- data/lib/hecks/fuzzing/replay.rb +1 -0
- data/lib/hecks/fuzzing/runtime_coverage.rb +70 -0
- data/lib/hecks/fuzzing/shrinker/value_simplification.rb +64 -0
- data/lib/hecks/fuzzing/shrinker.rb +3 -0
- data/lib/hecks/fuzzing.rb +3 -0
- data/lib/hecks/gate/stages.yml +1 -1
- data/lib/hecks/hecks/adapters/deploy_toolchain/answers.rb +2 -1
- data/lib/hecks/hecks/adapters/github_rulesets.rb +7 -5
- data/lib/hecks/hecks/adapters/process_pool.rb +26 -4
- data/lib/hecks/hecks/custodian.behaviors +6 -0
- data/lib/hecks/hecks/custodian.bluebook +37 -0
- data/lib/hecks/hecks/hecks.hecksagon +6 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +3 -0
- data/lib/hecks/projections/deploy/box/roll_scripts.rb +6 -4
- data/lib/hecks/projections/deploy/box/settings/patterns.rb +4 -0
- data/lib/hecks/projections/deploy/box/settings/types.rb +7 -3
- data/lib/hecks/projections/deploy/box/settings.rb +18 -0
- data/lib/hecks/projections/deploy/box/shared_database.rb +69 -0
- data/lib/hecks/projections/deploy/box/templates/Makefile.tmpl +6 -7
- data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +1 -1
- data/lib/hecks/projections/deploy/box/templates/provision-database.sh.tmpl +97 -0
- data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +4 -2
- data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +2 -1
- data/lib/hecks/projections/deploy/box.rb +14 -2
- data/lib/hecks/projections/deploy/shared_database_instance/templates/Makefile.tmpl +11 -0
- data/lib/hecks/projections/deploy/shared_database_instance/templates/README.md.tmpl +25 -0
- data/lib/hecks/projections/deploy/shared_database_instance/templates/rds.yaml.tmpl +180 -0
- data/lib/hecks/projections/deploy/shared_database_instance.rb +106 -0
- data/lib/hecks/projections/deploy/vercel/settings.rb +83 -0
- data/lib/hecks/projections/deploy/vercel.rb +134 -0
- data/lib/hecks/projections/site/site_host/templates.rb +12 -4
- data/lib/hecks/projections.rb +2 -0
- data/lib/hecks/quality_control/adapters/github_checks.rb +9 -2
- data/lib/hecks/quality_control/cli/qa_sweep/dials.rb +1 -0
- data/lib/hecks/quality_control/cli/qa_sweep/seed_loop.rb +10 -2
- data/lib/hecks/rust_build/native_build.rb +29 -1
- data/lib/hecks/rust_build/wasm.rb +16 -3
- data/lib/hecks/tools/deploy_recipe/generation.rb +59 -0
- data/lib/hecks/tools/deploy_recipe/targets.rb +56 -2
- data/lib/hecks/tools/deploy_recipe/worlds.rb +2 -1
- data/lib/hecks/tools/deploy_recipe.rb +20 -10
- data/lib/hecks/tools/fuzz_sweep/options.rb +21 -10
- data/lib/hecks/tools/fuzz_sweep/reporting.rb +25 -3
- data/lib/hecks/tools/fuzz_sweep.rb +26 -9
- data/lib/hecks/tools/lanes/files.rb +2 -4
- data/lib/hecks/tools/lanes/live.rb +3 -4
- data/lib/hecks/tools/lanes/ruleset.rb +23 -1
- data/lib/hecks/tools/lanes.rb +12 -1
- data/lib/hecks/tools/mutation_run.rb +73 -0
- data/lib/hecks/tools.rb +1 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +2 -0
- data/prebuilt/945481eefbdb584649d45f256307053558966a868b9c38dee7ee88f8169610f8.marshal +0 -0
- data/prebuilt/a0818e2de67364dc1ab22868b6cbf869c62da6f8d9b924c778fa5cce2e76ee94.marshal +0 -0
- data/prebuilt/verdicts-be73f0df9eee0abe2f4aadc968321c4b919d19d424a3ccfe1777a71dabf7d9e7.json +1 -0
- data/qa/settings.yml +1 -0
- data/rust/host/Cargo.toml +2 -2
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/src/api.rs +28 -5
- data/rust/host/src/auth.rs +11 -1
- data/rust/host/src/boundary_fuzz/api.rs +260 -0
- data/rust/host/src/boundary_fuzz/auth.rs +119 -0
- data/rust/host/src/boundary_fuzz/expr_json.rs +158 -0
- data/rust/host/src/boundary_fuzz/ir.rs +78 -0
- data/rust/host/src/boundary_fuzz/server.rs +227 -0
- data/rust/host/src/boundary_fuzz/web.rs +125 -0
- data/rust/host/src/fuzz_support.rs +165 -0
- data/rust/host/src/ir.rs +4 -0
- data/rust/host/src/main.rs +9 -4
- data/rust/host/src/secrets.rs +44 -0
- data/rust/host/src/server.rs +6 -1
- data/rust/host/src/web.rs +12 -2
- data/rust/parser/fuzz/.gitignore +4 -0
- data/rust/parser/fuzz/Cargo.lock +89 -0
- data/rust/parser/fuzz/Cargo.toml +27 -0
- data/rust/parser/fuzz/README.md +19 -0
- data/rust/parser/fuzz/fuzz_targets/chapter.rs +50 -0
- data/rust/parser/fuzz/fuzz_targets/resolve.rs +35 -0
- data/rust/parser/src/parse/command.rs +14 -2
- data/rust/parser/src/parse/mod.rs +25 -0
- data/rust/parser/src/ruby_value.rs +95 -6
- data/rust/src/kernel/json.rs +34 -3
- metadata +50 -1
data/rust/host/src/secrets.rs
CHANGED
|
@@ -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!(
|
data/rust/host/src/server.rs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
1611
|
-
|
|
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,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::
|
|
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::
|
|
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()));
|
data/rust/src/kernel/json.rs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
}
|