mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Match NBT path quoted escapes and index tokens
This commit is contained in:
1 parent
f4dcf8a2fb
commit
db25d1d092
4 files changed
+193
-3
No files matched your search
@@ -0,0 +1,22 @@
|
||||
//! NBT path parser output and byte cursor for the pinned Java oracle.
|
||||
use horizon_client_core::nbt_path::NbtPath;
|
||||
use std::io::{BufRead, Write};
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let input = std::io::BufReader::new(std::fs::File::open(args.next().unwrap())?);
|
||||
let mut output = std::io::BufWriter::new(std::fs::File::create(args.next().unwrap())?);
|
||||
for line in input.lines() {
|
||||
let text: String = serde_json::from_str(&line?)?;
|
||||
match NbtPath::parse_prefix(&text) {
|
||||
Ok((path, cursor)) => {
|
||||
write!(output, "OK {cursor} ")?;
|
||||
for value in path.as_str().as_bytes() {
|
||||
write!(output, "{value:02x}")?;
|
||||
}
|
||||
writeln!(output)?;
|
||||
}
|
||||
Err(error) => writeln!(output, "ERR {} {}", error.cursor, error.key)?,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -726,11 +726,16 @@ fn parse_node(text: &str, i: usize, first: bool) -> Result<(Node, usize), PathEr
|
||||
Some(b']') => Ok((Node::AllElements, j + 1)),
|
||||
_ => {
|
||||
let start = j;
|
||||
if b.get(j) == Some(&b'-') {
|
||||
// StringReader.readInt scans all allowed number characters
|
||||
// before parsing, including malformed signs and decimal points.
|
||||
while b
|
||||
.get(j)
|
||||
.is_some_and(|c| c.is_ascii_digit() || matches!(c, b'-' | b'.'))
|
||||
{
|
||||
j += 1;
|
||||
}
|
||||
while b.get(j).is_some_and(u8::is_ascii_digit) {
|
||||
j += 1;
|
||||
if start == j {
|
||||
return Err(PathError::new("parsing.int.expected", vec![], start));
|
||||
}
|
||||
let n: i32 = text[start..j].parse().map_err(|_| {
|
||||
PathError::new(
|
||||
@@ -792,6 +797,13 @@ fn read_quoted(text: &str, i: usize) -> Result<(String, usize), PathError> {
|
||||
let mut escaped = false;
|
||||
for (o, c) in text[i + 1..].char_indices() {
|
||||
if escaped {
|
||||
if c != quote && c != '\\' {
|
||||
return Err(PathError::new(
|
||||
"parsing.quote.escape",
|
||||
vec![c.to_string()],
|
||||
i + 1 + o,
|
||||
));
|
||||
}
|
||||
out.push(c);
|
||||
escaped = false;
|
||||
} else if c == '\\' {
|
||||
@@ -840,6 +852,40 @@ fn parse_compound(text: &str, i: usize) -> Result<(Tag, usize), PathError> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn quoted_escapes_and_index_tokens_match_brigadier() {
|
||||
let cases = [
|
||||
(r#""a\q""#, "parsing.quote.escape", vec!["q"], 3),
|
||||
(r#"'a\x'"#, "parsing.quote.escape", vec!["x"], 3),
|
||||
("[1.0]", "parsing.int.invalid", vec!["1.0"], 1),
|
||||
("[.]", "parsing.int.invalid", vec!["."], 1),
|
||||
("[--1]", "parsing.int.invalid", vec!["--1"], 1),
|
||||
("[2147483648]", "parsing.int.invalid", vec!["2147483648"], 1),
|
||||
("[+1]", "parsing.int.expected", vec![], 1),
|
||||
(r#""é\q""#, "parsing.quote.escape", vec!["q"], 4),
|
||||
(r#""é"[1.0]"#, "parsing.int.invalid", vec!["1.0"], 5),
|
||||
];
|
||||
let mut failures = Vec::new();
|
||||
for (text, key, args, cursor) in cases {
|
||||
match NbtPath::parse_prefix(text) {
|
||||
Err(error) if error.key == key && error.args == args && error.cursor == cursor => {}
|
||||
other => failures.push(format!("{text:?}: {other:?}")),
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_quoted_escapes_still_select_the_exact_field() {
|
||||
let root = Tag::compound([("a\"b", Tag::Int(7)), ("a\\b", Tag::Int(8))]);
|
||||
for (path, value) in [(r#""a\"b""#, 7), (r#""a\\b""#, 8)] {
|
||||
assert_eq!(
|
||||
NbtPath::parse(path).unwrap().get(&root).unwrap(),
|
||||
vec![Tag::Int(value)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_provenance_handles_quotes_and_filters_without_matching_lists() {
|
||||
for text in [
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package dev.horizonlab.parity;
|
||||
|
||||
import com.google.gson.JsonParser;
|
||||
import com.mojang.brigadier.StringReader;
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import net.minecraft.commands.arguments.NbtPathArgument;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.network.chat.contents.TranslatableContents;
|
||||
|
||||
/** Real NbtPathArgument parsing only: no game bootstrap, resources or server. */
|
||||
public final class VanillaNbtPathReaderOracle {
|
||||
public static void main(String[] args) throws Exception {
|
||||
try (var out = Files.newBufferedWriter(Path.of(args[1]), StandardCharsets.UTF_8)) {
|
||||
for (String line : Files.readAllLines(Path.of(args[0]), StandardCharsets.UTF_8)) {
|
||||
String text = JsonParser.parseString(line).getAsString();
|
||||
StringReader reader = new StringReader(text);
|
||||
try {
|
||||
var path = NbtPathArgument.nbtPath().parse(reader);
|
||||
out.write("OK " + utf8Cursor(text, reader.getCursor()) + " ");
|
||||
for (byte value : path.toString().getBytes(StandardCharsets.UTF_8))
|
||||
out.write(String.format("%02x", value & 255));
|
||||
} catch (CommandSyntaxException error) {
|
||||
var built = CommandSyntaxException.BUILT_IN_EXCEPTIONS;
|
||||
String key = error.getType() == built.readerInvalidEscape() ? "parsing.quote.escape"
|
||||
: error.getType() == built.readerExpectedEndOfQuote() ? "parsing.quote.expected.end"
|
||||
: error.getType() == built.readerInvalidInt() ? "parsing.int.invalid"
|
||||
: error.getType() == built.readerExpectedInt() ? "parsing.int.expected"
|
||||
: error.getType() == built.readerExpectedSymbol() ? "parsing.expected"
|
||||
: error.getRawMessage() instanceof Component component
|
||||
&& component.getContents() instanceof TranslatableContents contents
|
||||
? contents.getKey() : "unknown";
|
||||
out.write("ERR " + utf8Cursor(text, reader.getCursor()) + " " + key);
|
||||
}
|
||||
out.newLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
private static int utf8Cursor(String text, int cursor) {
|
||||
return text.substring(0, cursor).getBytes(StandardCharsets.UTF_8).length;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
"""NBT path quote/index lexical rules, result and byte cursor against vanilla.
|
||||
|
||||
Uses the real NbtPathArgument/StringReader without a server or prepared resources.
|
||||
Covers quoted escapes, Unicode keys and prefixes, numeric index tokens, empty and
|
||||
unterminated controls. Error categories/cursors are compared, not localized text
|
||||
or invalid-escape argument UTF-16 representation. SNBT filters and path operations
|
||||
and the vanilla unchecked bare-bracket exception are outside this parser-only corpus.
|
||||
"""
|
||||
import itertools
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import random
|
||||
try:
|
||||
from . import lab, oracle_harness as h, test_rust_parity as base
|
||||
except ImportError:
|
||||
import lab, oracle_harness as h, test_rust_parity as base
|
||||
|
||||
|
||||
def fixtures():
|
||||
values = ['', ' ', 'a', 'a.b', 'a.', 'a..b', '[]', '[0]', '[-1]',
|
||||
'a[2147483647]', 'a[-2147483648]', 'a[2147483648]',
|
||||
'a[-2147483649]', 'a[1.0]', 'a[.]', 'a[ ]', 'a[+1]']
|
||||
for quote, prefix in itertools.product(('"', "'"), ('', 'a', '\u00e9', '\U0001f600')):
|
||||
for char in [chr(i) for i in range(128)] + ['\u00e9', '\u4e2d', '\U0001f600']:
|
||||
values.append(quote + prefix + '\\' + char + quote)
|
||||
for key in ('', 'plain', 'a.b', 'a[b]', 'a b', '\u00e9', '\U0001f600', 'a\u4e2d\U0001f600b'):
|
||||
values.extend([quote + key + quote, quote + key, quote + key + '\\'])
|
||||
rng = random.Random(0x11039)
|
||||
for _ in range(2500):
|
||||
token = ''.join(rng.choice('0129-.') for _ in range(rng.randrange(1, 18)))
|
||||
for prefix in ('', 'a', '"\u00e9"', '"\U0001f600"'):
|
||||
values.append(prefix + '[' + token + ']')
|
||||
for prefix, token, suffix in itertools.product(('', 'list'),
|
||||
('', '-', '.', '--1', '1.2', '1', '-2', '0'), ('', ']', '+]', 'x]', ' ]')):
|
||||
values.append(prefix + '[' + token + suffix)
|
||||
# Vanilla parseNode peeks past a bare terminal '[' and throws an unchecked
|
||||
# exception. This corpus checks command syntax errors, not that crash.
|
||||
return [value for value in dict.fromkeys(values) if not value.endswith('[')]
|
||||
|
||||
|
||||
def main():
|
||||
lock, jars, libraries = base.verify_artifacts()
|
||||
build = lab.LOCAL / 'nbt-path-reader-parity'
|
||||
build.mkdir(parents=True, exist_ok=True)
|
||||
report = build / 'report.json'
|
||||
report.unlink(missing_ok=True)
|
||||
paths = ['crates/horizon-client-core/src/nbt_path.rs',
|
||||
'crates/horizon-client-core/examples/nbt_path_reader_probe.rs',
|
||||
'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaNbtPathReaderOracle.java',
|
||||
'tools/test_nbt_path_reader_parity.py']
|
||||
sources = [lab.ROOT / path for path in paths]
|
||||
hashes = base.source_hashes(sources)
|
||||
cases = fixtures()
|
||||
inputs = build / 'inputs.jsonl'
|
||||
inputs.write_text(''.join(json.dumps(text) + '\n' for text in cases), encoding='utf-8')
|
||||
classes, dependencies, _ = h.compile_oracle(build, 'VanillaNbtPathReaderOracle',
|
||||
{'client': jars['server']}, libraries, lock['minecraft']['java_major'])
|
||||
h.java_run(build, classes, dependencies, 'VanillaNbtPathReaderOracle',
|
||||
[inputs, build / 'java.txt'], 'java')
|
||||
h.run_logged(['cargo', 'build', '--offline', '--locked', '-p', 'horizon-client-core',
|
||||
'--example', 'nbt_path_reader_probe'], build, 'compile-rust',
|
||||
env=dict(os.environ, CARGO_BUILD_JOBS=os.environ.get('CARGO_BUILD_JOBS', '2')))
|
||||
binary = lab.cargo_target() / 'debug/examples' / (
|
||||
'nbt_path_reader_probe.exe' if os.name == 'nt' else 'nbt_path_reader_probe')
|
||||
h.run_logged([binary, inputs, build / 'rust.txt'], build, 'rust')
|
||||
matched = h.compare(build / 'java.txt', build / 'rust.txt', len(cases))
|
||||
base.ensure_sources_unchanged(sources, hashes)
|
||||
lab.write_json(report, dict(schema=1, minecraft=lock['minecraft']['version'],
|
||||
cases=len(cases), matched_output_lines=matched, sources_sha256=hashes,
|
||||
input_sha256=lab.digest(inputs, 'sha256'),
|
||||
server_jar_sha256=lab.digest(jars['server'], 'sha256'), scope=__doc__))
|
||||
print(f'{matched} NBT path reader cases match the pinned server: {report}', flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in new issue
Block a user