Skip to content

Commit e828a08

Browse files
feat(rust): make PyO3 optional to fix Python 3.14 fuzz linking
- Make pyo3 dependency optional behind 'python' feature flag - Add rlib crate-type to support both cdylib (Python) and rlib (fuzzing) - Gate all Python-specific code with #[cfg(feature = "python")] - Make pure Rust functions (escape_xml, wrap_cdata, etc.) public for fuzzing - Add comprehensive unit tests for XML utility functions Fixes linker error with Python 3.14 where PyUnicode_DATA and PyUnicode_KIND symbols are now inline macros, not exported functions. Fuzz targets can now build without linking against Python. Amp-Thread-ID: https://ampcode.com/threads/T-019c0425-ac62-76d8-9d59-4d6aba3edf45 Co-authored-by: Amp <[email protected]>
1 parent d346999 commit e828a08

File tree

10 files changed

+451
-7
lines changed

10 files changed

+451
-7
lines changed

rust/Cargo.toml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,14 @@ license = "Apache-2.0"
77

88
[lib]
99
name = "json2xml_rs"
10-
crate-type = ["cdylib"]
10+
crate-type = ["cdylib", "rlib"]
11+
12+
[features]
13+
default = ["python"]
14+
python = ["pyo3/extension-module", "dep:pyo3"]
1115

1216
[dependencies]
13-
pyo3 = { version = "0.27", features = ["extension-module"] }
17+
pyo3 = { version = "0.27", optional = true }
1418

1519
[profile.release]
1620
lto = true

rust/fuzz/Cargo.toml

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
[package]
2+
name = "json2xml_rs-fuzz"
3+
version = "0.0.0"
4+
publish = false
5+
edition = "2021"
6+
7+
[package.metadata]
8+
cargo-fuzz = true
9+
10+
[dependencies]
11+
libfuzzer-sys = "0.4"
12+
arbitrary = { version = "1", features = ["derive"] }
13+
14+
[dependencies.json2xml_rs]
15+
path = ".."
16+
default-features = false
17+
18+
[[bin]]
19+
name = "fuzz_escape_xml"
20+
path = "fuzz_targets/fuzz_escape_xml.rs"
21+
test = false
22+
doc = false
23+
bench = false
24+
25+
[[bin]]
26+
name = "fuzz_wrap_cdata"
27+
path = "fuzz_targets/fuzz_wrap_cdata.rs"
28+
test = false
29+
doc = false
30+
bench = false
31+
32+
[[bin]]
33+
name = "fuzz_is_valid_xml_name"
34+
path = "fuzz_targets/fuzz_is_valid_xml_name.rs"
35+
test = false
36+
doc = false
37+
bench = false
38+
39+
[[bin]]
40+
name = "fuzz_make_valid_xml_name"
41+
path = "fuzz_targets/fuzz_make_valid_xml_name.rs"
42+
test = false
43+
doc = false
44+
bench = false
45+
46+
[[bin]]
47+
name = "fuzz_make_attr_string"
48+
path = "fuzz_targets/fuzz_make_attr_string.rs"
49+
test = false
50+
doc = false
51+
bench = false
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
2+
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
#![no_main]
2+
3+
use libfuzzer_sys::fuzz_target;
4+
use json2xml_rs::escape_xml;
5+
6+
fuzz_target!(|data: &str| {
7+
let result = escape_xml(data);
8+
9+
// Verify invariants:
10+
// 1. Result should not contain unescaped special chars
11+
assert!(!result.contains('&') || result.contains("&amp;") || result.contains("&quot;")
12+
|| result.contains("&apos;") || result.contains("&lt;") || result.contains("&gt;"));
13+
14+
// 2. Result should be valid (no panics occurred)
15+
// 3. If input had no special chars, output equals input
16+
if !data.contains('&') && !data.contains('"') && !data.contains('\'')
17+
&& !data.contains('<') && !data.contains('>') {
18+
assert_eq!(result, data);
19+
}
20+
21+
// 4. Output length should be >= input length (escaping only adds chars)
22+
assert!(result.len() >= data.len());
23+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
#![no_main]
2+
3+
use libfuzzer_sys::fuzz_target;
4+
use json2xml_rs::is_valid_xml_name;
5+
6+
fuzz_target!(|data: &str| {
7+
let result = is_valid_xml_name(data);
8+
9+
// Verify invariants:
10+
// 1. Empty string is always invalid
11+
if data.is_empty() {
12+
assert!(!result);
13+
}
14+
15+
// 2. String starting with digit is invalid
16+
if let Some(first) = data.chars().next() {
17+
if first.is_ascii_digit() {
18+
assert!(!result);
19+
}
20+
}
21+
22+
// 3. String starting with "xml" (case-insensitive) is invalid
23+
if data.to_lowercase().starts_with("xml") {
24+
assert!(!result);
25+
}
26+
27+
// 4. String containing spaces is invalid
28+
if data.contains(' ') {
29+
assert!(!result);
30+
}
31+
32+
// 5. Function should never panic - reaching here means it didn't
33+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
#![no_main]
2+
3+
use libfuzzer_sys::fuzz_target;
4+
use arbitrary::Arbitrary;
5+
use json2xml_rs::make_attr_string;
6+
7+
#[derive(Arbitrary, Debug)]
8+
struct AttrInput {
9+
attrs: Vec<(String, String)>,
10+
}
11+
12+
fuzz_target!(|input: AttrInput| {
13+
let result = make_attr_string(&input.attrs);
14+
15+
// Verify invariants:
16+
// 1. Empty attrs should produce empty string
17+
if input.attrs.is_empty() {
18+
assert!(result.is_empty());
19+
return;
20+
}
21+
22+
// 2. Result should start with space (for XML formatting)
23+
assert!(result.starts_with(' '), "Attribute string should start with space");
24+
25+
// 3. Each attribute should produce key="value" format
26+
for (key, _value) in &input.attrs {
27+
// Key should appear in the result
28+
assert!(result.contains(key), "Key '{}' should appear in result", key);
29+
}
30+
31+
// 4. Values should be escaped (no raw & < > " ' in values)
32+
// The make_attr_string calls escape_xml on values
33+
34+
// 5. Function should never panic - reaching here means it didn't
35+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
#![no_main]
2+
3+
use libfuzzer_sys::fuzz_target;
4+
use json2xml_rs::make_valid_xml_name;
5+
6+
fuzz_target!(|data: &str| {
7+
let (name, attr) = make_valid_xml_name(data);
8+
9+
// Verify invariants:
10+
// 1. The returned name must be a valid XML name OR be "key" with an attribute
11+
if name != "key" {
12+
// If we didn't fall back to "key", the name should be valid
13+
// (though it might have been transformed)
14+
assert!(!name.is_empty(), "Name should not be empty");
15+
}
16+
17+
// 2. If attr is Some, name should be "key"
18+
if attr.is_some() {
19+
assert_eq!(name, "key", "Fallback name should be 'key'");
20+
let (attr_name, _attr_value) = attr.unwrap();
21+
assert_eq!(attr_name, "name", "Attribute key should be 'name'");
22+
}
23+
24+
// 3. Purely numeric input should get 'n' prefix
25+
if !data.is_empty() && data.chars().all(|c| c.is_ascii_digit()) {
26+
assert!(name.starts_with('n'), "Numeric keys should get 'n' prefix");
27+
}
28+
29+
// 4. Function should never panic - reaching here means it didn't
30+
});
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#![no_main]
2+
3+
use libfuzzer_sys::fuzz_target;
4+
use json2xml_rs::wrap_cdata;
5+
6+
fuzz_target!(|data: &str| {
7+
let result = wrap_cdata(data);
8+
9+
// Verify invariants:
10+
// 1. Result must start with CDATA opening
11+
assert!(result.starts_with("<![CDATA["));
12+
13+
// 2. Result must end with CDATA closing
14+
assert!(result.ends_with("]]>"));
15+
16+
// 3. The ]]> sequence in input must be properly escaped
17+
// (split into ]]]]><![CDATA[>)
18+
19+
// 4. Result should be longer than or equal to input + CDATA wrapper (12 chars)
20+
assert!(result.len() >= data.len() + 12);
21+
});

0 commit comments

Comments
 (0)