diff --git a/crates/horizon-protocol/examples/protocol_identifiers_probe.rs b/crates/horizon-protocol/examples/protocol_identifiers_probe.rs new file mode 100644 index 0000000000..8cf389c462 --- /dev/null +++ b/crates/horizon-protocol/examples/protocol_identifiers_probe.rs @@ -0,0 +1,37 @@ +//! The packet identifier parity corpus, without a connection or game resources. +use horizon_protocol::buf::{Codec, Ident, Reader}; +use std::{ + error::Error, + fs::File, + io::{BufRead, BufReader, BufWriter, Write}, +}; + +fn main() -> Result<(), Box> { + let args: Vec<_> = std::env::args().collect(); + let mut output = BufWriter::new(File::create(&args[2])?); + for line in BufReader::new(File::open(&args[1])?).lines() { + let hex = line?; + if hex.len() % 2 != 0 { + return Err("odd hex length".into()); + } + let bytes = hex + .as_bytes() + .chunks_exact(2) + .map(|pair| { + u8::from_str_radix(std::str::from_utf8(pair)?, 16).map_err(Box::::from) + }) + .collect::, Box>>()?; + let mut reader = Reader::new(&bytes); + match Ident::read(&mut reader) { + Ok(value) => { + write!(output, "OK {} ", reader.position())?; + for unit in value.encode_utf16() { + write!(output, "{unit:04x}")?; + } + writeln!(output)?; + } + Err(_) => writeln!(output, "ERR {}", reader.position())?, + } + } + Ok(()) +} diff --git a/crates/horizon-protocol/src/buf.rs b/crates/horizon-protocol/src/buf.rs index 33d9b06389..173df48c0c 100644 --- a/crates/horizon-protocol/src/buf.rs +++ b/crates/horizon-protocol/src/buf.rs @@ -343,8 +343,32 @@ impl Codec for StrN { w.string(v) } } -/// Identifier.STREAM_CODEC: a resource location string. -pub type Ident = StrN<32767>; +/// Identifier.STREAM_CODEC: a validated resource location, with the default namespace. +pub struct Ident; +impl Codec for Ident { + fn read(r: &mut Reader) -> Result { + let value = r.string(32767)?; + let (namespace, path) = value.split_once(':').unwrap_or(("", &value)); + let namespace = if namespace.is_empty() { + "minecraft" + } else { + namespace + }; + if !namespace + .bytes() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || b"_.-".contains(&c)) + || !path + .bytes() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || b"/_.-".contains(&c)) + { + return Err(Error::new("invalid resource identifier")); + } + Ok(format!("{namespace}:{path}")) + } + fn write(v: &String, w: &mut Writer) { + w.string(v) + } +} /// VarInt-prefixed bytes. pub struct Bytes; impl Codec> for Bytes { diff --git a/crates/horizon-protocol/tests/identifier_codec.rs b/crates/horizon-protocol/tests/identifier_codec.rs new file mode 100644 index 0000000000..1a376ce12a --- /dev/null +++ b/crates/horizon-protocol/tests/identifier_codec.rs @@ -0,0 +1,85 @@ +use horizon_protocol::{ + buf::{Codec, Ident, Reader, Str, Writer}, + packets::{configuration, play}, +}; + +fn wire(value: &str) -> Vec { + let mut writer = Writer::new(); + writer.string(value); + writer.data +} + +#[test] +fn identifiers_normalize_default_namespace_without_restricting_empty_paths() { + for (input, expected) in [ + ("stone", "minecraft:stone"), + (":stone", "minecraft:stone"), + ("", "minecraft:"), + (":", "minecraft:"), + ("mod:", "mod:"), + ("mod:dir/file.-_09", "mod:dir/file.-_09"), + ] { + let bytes = wire(input); + let mut reader = Reader::new(&bytes); + assert_eq!(Ident::read(&mut reader).unwrap(), expected, "{input:?}"); + assert_eq!(reader.remaining(), 0); + } +} + +#[test] +fn identifier_validation_does_not_change_ordinary_packet_strings() { + for input in [ + "STONE", + "mod:UPPER", + "a:b:c", + "a+b:c", + "a/b:c", + "a b:c", + "a:é", + ] { + let bytes = wire(input); + assert!(Ident::read(&mut Reader::new(&bytes)).is_err(), "{input:?}"); + assert_eq!(Str::read(&mut Reader::new(&bytes)).unwrap(), input); + } +} + +#[test] +fn configuration_and_play_custom_payloads_share_identifier_decoding() { + let mut body = wire("request_secret"); + body.extend_from_slice(&[0, 1, 255]); + let cfg = configuration::Clientbound::decode( + horizon_protocol::ids::configuration::clientbound::CUSTOM_PAYLOAD, + &body, + ) + .unwrap(); + let configuration::Clientbound::CustomPayload(payload) = cfg else { + panic!("wrong packet") + }; + assert_eq!(payload.channel, "minecraft:request_secret"); + assert_eq!(payload.data, [0, 1, 255]); + let packet = play::Clientbound::decode( + horizon_protocol::ids::play::clientbound::CUSTOM_PAYLOAD, + &body, + ) + .unwrap(); + let play::Clientbound::CustomPayload(payload) = packet else { + panic!("wrong packet") + }; + assert_eq!(payload.channel, "minecraft:request_secret"); + assert_eq!(payload.data, [0, 1, 255]); + let invalid = wire("bad:CHANNEL"); + assert!( + configuration::Clientbound::decode( + horizon_protocol::ids::configuration::clientbound::CUSTOM_PAYLOAD, + &invalid + ) + .is_err() + ); + assert!( + play::Clientbound::decode( + horizon_protocol::ids::play::clientbound::CUSTOM_PAYLOAD, + &invalid + ) + .is_err() + ); +} diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaProtocolIdentifierOracle.java b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaProtocolIdentifierOracle.java new file mode 100644 index 0000000000..aa00d23d49 --- /dev/null +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaProtocolIdentifierOracle.java @@ -0,0 +1,29 @@ +package dev.horizonlab.parity; + +import io.netty.buffer.Unpooled; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.charset.StandardCharsets; +import java.util.HexFormat; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.resources.Identifier; + +/** Identifier.STREAM_CODEC against raw packet bytes; no game/server bootstrap. */ +public final class VanillaProtocolIdentifierOracle { + public static void main(String[] args) throws Exception { + try (var output = Files.newBufferedWriter(Path.of(args[1]), StandardCharsets.UTF_8)) { + for (String line : Files.readAllLines(Path.of(args[0]), StandardCharsets.UTF_8)) { + var buffer = new FriendlyByteBuf(Unpooled.wrappedBuffer(HexFormat.of().parseHex(line))); + try { + String value = Identifier.STREAM_CODEC.decode(buffer).toString(); + output.write("OK " + buffer.readerIndex() + " "); + for (int i = 0; i < value.length(); i++) output.write(String.format("%04x", (int) value.charAt(i))); + output.newLine(); + } catch (RuntimeException error) { + output.write("ERR " + buffer.readerIndex()); + output.newLine(); + } finally { buffer.release(); } + } + } + } +} diff --git a/tools/test_protocol_identifiers_parity.py b/tools/test_protocol_identifiers_parity.py new file mode 100644 index 0000000000..952f64f153 --- /dev/null +++ b/tools/test_protocol_identifiers_parity.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Identifier.STREAM_CODEC packet reads, independent of game resources.""" +import json +import os +from pathlib import Path +import random +import subprocess + +try: + from . import lab, oracle_harness as h, test_rust_parity as base +except ImportError: + import lab + import oracle_harness as h + import test_rust_parity as base + + +def fixtures(): + def packet(value): + raw = value.encode('utf-8') + length, prefix = len(raw), bytearray() + while length > 127: + prefix.append((length & 127) | 128) + length >>= 7 + prefix.append(length) + return bytes(prefix) + raw + + values = ['', ':', 'stone', ':stone', 'minecraft:stone', 'mod:', 'a:b:c', + 'A:b', 'a:B', 'a/b:c', 'a+b:c', 'a b:c', 'a:b/c.d-e_f09', + 'stone' * 6553, 'a' * 32767, 'a' * 32768] + for code in range(256): + char = chr(code) + values.extend([char, 'a' + char + ':path', 'ns:dir/' + char]) + rng = random.Random(0x1DE17) + alphabet = 'abcxyz01239._-/:' + for _ in range(2500): + values.append(''.join(rng.choice(alphabet) for _ in range(rng.randrange(32)))) + cases = [packet(value) for value in values] + cases.extend([bytes.fromhex(x) for x in ['', '01', '036162', 'ffffffff0f', + '8080808080', '03eda080', '02c080', '0261ff']]) + return list(dict.fromkeys(cases)) + + +def main(): + lock, jars, libraries = base.verify_artifacts() + build = lab.LOCAL / 'protocol-identifiers-parity' + build.mkdir(parents=True, exist_ok=True) + paths = ['crates/horizon-protocol/src/buf.rs', + 'crates/horizon-protocol/src/packets/configuration.rs', + 'crates/horizon-protocol/src/packets/play.rs', + 'crates/horizon-protocol/examples/protocol_identifiers_probe.rs', + 'crates/horizon-protocol/tests/identifier_codec.rs', + 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaProtocolIdentifierOracle.java', + 'tools/test_protocol_identifiers_parity.py'] + sources = [lab.ROOT / path for path in paths] + hashes = base.source_hashes(sources) + cases = fixtures() + inputs = build / 'inputs.txt' + inputs.write_text(''.join(case.hex() + '\n' for case in cases), encoding='utf-8') + input_hash = lab.digest(inputs, 'sha256') + classes, dependencies, _ = h.compile_oracle(build, 'VanillaProtocolIdentifierOracle', + {'client': jars['server']}, libraries, lock['minecraft']['java_major']) + h.java_run(build, classes, dependencies, 'VanillaProtocolIdentifierOracle', + [inputs, build / 'java.txt'], 'java') + h.run_logged(['cargo', 'build', '--locked', '-p', 'horizon-protocol', + '--example', 'protocol_identifiers_probe'], build, 'compile-rust', + env=dict(os.environ, CARGO_BUILD_JOBS='2')) + binary = lab.cargo_target() / 'debug/examples' / ( + 'protocol_identifiers_probe.exe' if os.name == 'nt' else 'protocol_identifiers_probe') + h.run_logged([binary, inputs, build / 'rust.txt'], build, 'rust') + matched = base.compare(build / 'java.txt', build / 'rust.txt') + base.ensure_sources_unchanged(sources, hashes) + if lab.digest(inputs, 'sha256') != input_hash: + raise AssertionError('Input fixtures changed') + report = {'minecraft': lock['minecraft']['version'], 'cases': len(cases), + 'manifest_sha1': lock['minecraft']['manifest_sha1'], + 'server_jar_sha256': lab.digest(jars['server'], 'sha256'), + 'matched': matched, 'sources_sha256': hashes, + 'input_sha256': input_hash, + 'scope': 'Identifier.STREAM_CODEC decoding, normalized text and reader position; ' + 'includes real CustomPayload regression tests separately; encoding unchanged'} + lab.write_json(build / 'report.json', report) + print(f'{matched} packet identifier cases match pinned Minecraft: {build / "report.json"}') + + +if __name__ == '__main__': + main()