mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 02:56:17 -10:00
Validate packet resource identifiers when decoding
This commit is contained in:
1 parent
f4dcf8a2fb
commit
5fd9d40d4c
5 files changed
+263
-2
No files matched your search
@@ -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<dyn Error>> {
|
||||
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::<dyn Error>::from)
|
||||
})
|
||||
.collect::<Result<Vec<_>, Box<dyn Error>>>()?;
|
||||
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(())
|
||||
}
|
||||
@@ -343,8 +343,32 @@ impl<const N: usize> Codec<String> for StrN<N> {
|
||||
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<String> for Ident {
|
||||
fn read(r: &mut Reader) -> Result<String> {
|
||||
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<Vec<u8>> for Bytes {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
use horizon_protocol::{
|
||||
buf::{Codec, Ident, Reader, Str, Writer},
|
||||
packets::{configuration, play},
|
||||
};
|
||||
|
||||
fn wire(value: &str) -> Vec<u8> {
|
||||
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()
|
||||
);
|
||||
}
|
||||
@@ -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(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user