mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
feat(mods): validate source-only native mod bundles
Co-Authored-By: Hermes Agent (OpenAI) <[email protected]>
This commit is contained in:
1 parent
b6c936f82b
commit
137045e321
3 files changed
+250
-1
No files matched your search
@@ -97,6 +97,9 @@ impl Report {
|
||||
for r in &f.requires {
|
||||
out += &format!(" needs {} {} ({})\n", r.id, r.version, &r.sha256[..12]);
|
||||
}
|
||||
if self.kind == Kind::NativeMod {
|
||||
out += " native source: unsandboxed; not executed by the registry check. Compile and test against the exact pinned Horizon and Mixins revisions before review.\n";
|
||||
}
|
||||
if self.kind == Kind::Mod && !self.code {
|
||||
out += " no code: content only, nothing to run\n";
|
||||
} else if self.kind == Kind::Mod {
|
||||
@@ -118,6 +121,7 @@ impl Report {
|
||||
pub fn kind_of(path: &Path) -> Option<Kind> {
|
||||
match path.extension()?.to_str()? {
|
||||
"hmod" => Some(Kind::Mod),
|
||||
"hnmod" => Some(Kind::NativeMod),
|
||||
"hpack" => Some(Kind::Modpack),
|
||||
"zip" => Some(Kind::ResourcePack),
|
||||
"hshader" => Some(Kind::Shader),
|
||||
|
||||
@@ -43,6 +43,8 @@ pub struct Index {
|
||||
pub enum Kind {
|
||||
/// A `.hmod`.
|
||||
Mod,
|
||||
/// A source-only native mixin bundle, compiled into an isolated game build.
|
||||
NativeMod,
|
||||
/// A `.hpack`: registry mods played together as a profile.
|
||||
Modpack,
|
||||
/// A resource pack (`.zip` with `pack.mcmeta`) made or tested for Horizon.
|
||||
@@ -57,6 +59,7 @@ impl Kind {
|
||||
pub fn name(&self) -> &str {
|
||||
match self {
|
||||
Kind::Mod => "mod",
|
||||
Kind::NativeMod => "native-mod",
|
||||
Kind::Modpack => "modpack",
|
||||
Kind::ResourcePack => "resourcepack",
|
||||
Kind::Shader => "shader",
|
||||
@@ -68,6 +71,7 @@ impl Kind {
|
||||
pub fn extension(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Kind::Mod => Some("hmod"),
|
||||
Kind::NativeMod => Some("hnmod"),
|
||||
Kind::Modpack => Some("hpack"),
|
||||
Kind::ResourcePack => Some("zip"),
|
||||
Kind::Shader => Some(horizon_shader_api::EXTENSION),
|
||||
@@ -80,6 +84,7 @@ impl From<String> for Kind {
|
||||
fn from(name: String) -> Kind {
|
||||
match name.as_str() {
|
||||
"mod" => Kind::Mod,
|
||||
"native-mod" => Kind::NativeMod,
|
||||
"modpack" => Kind::Modpack,
|
||||
"resourcepack" => Kind::ResourcePack,
|
||||
"shader" => Kind::Shader,
|
||||
@@ -552,6 +557,7 @@ impl Facts {
|
||||
/// this build doesn't know are refused: their checks come with their formats.
|
||||
pub fn inspect(kind: &Kind, path: &Path) -> Result<Facts, String> {
|
||||
match kind {
|
||||
Kind::NativeMod => Ok(native::Bundle::read(path)?.manifest.facts()),
|
||||
Kind::Mod => {
|
||||
let Package { manifest: m, wasm } = Package::read(path)?;
|
||||
crate::Content::read(path, &m)?;
|
||||
@@ -656,7 +662,7 @@ fn check_resource_pack(path: &Path) -> Result<String, String> {
|
||||
/// formats.
|
||||
pub fn open(kind: &Kind) -> Result<(), String> {
|
||||
match kind {
|
||||
Kind::Mod | Kind::Modpack | Kind::ResourcePack | Kind::Shader => Ok(()),
|
||||
Kind::Mod | Kind::NativeMod | Kind::Modpack | Kind::ResourcePack | Kind::Shader => Ok(()),
|
||||
Kind::Other(name) => Err(format!("the registry doesn't take {name} yet")),
|
||||
}
|
||||
}
|
||||
@@ -666,6 +672,7 @@ fn names(caps: &[Capability]) -> String {
|
||||
format!("[{}]", names.join(", "))
|
||||
}
|
||||
|
||||
pub mod native;
|
||||
pub mod registry;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,238 @@
|
||||
//! Source-only native mixin bundles. Reading a bundle never executes its code.
|
||||
use super::{Facts, valid_id};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{collections::BTreeMap, io::Read, path::Path};
|
||||
|
||||
pub const MANIFEST: &str = "horizon-native.json";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Manifest {
|
||||
pub schema: u32,
|
||||
pub id: String,
|
||||
pub version: String,
|
||||
pub game: String,
|
||||
/// Exact source revisions tested together, never mutable tags.
|
||||
pub horizon: String,
|
||||
pub mixins: String,
|
||||
}
|
||||
|
||||
pub fn revision(value: &str) -> bool {
|
||||
value.len() == 40
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
pub fn check(&self) -> Result<(), String> {
|
||||
if self.schema != 1
|
||||
|| !valid_id(&self.id)
|
||||
|| self.version.is_empty()
|
||||
|| self.game.is_empty()
|
||||
|| !revision(&self.horizon)
|
||||
|| !revision(&self.mixins)
|
||||
{
|
||||
return Err(
|
||||
"invalid native manifest: schema 1, id, version, game and exact revisions required"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Carries the exact native compatibility contract in the index's API field.
|
||||
pub fn api(&self) -> String {
|
||||
format!("native-1/{}/{}", self.horizon, self.mixins)
|
||||
}
|
||||
|
||||
pub fn facts(&self) -> Facts {
|
||||
Facts {
|
||||
id: Some(self.id.clone()),
|
||||
version: Some(self.version.clone()),
|
||||
api: self.api(),
|
||||
game: Some(self.game.clone()),
|
||||
..Facts::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Bundle {
|
||||
pub manifest: Manifest,
|
||||
/// Validated relative paths; only TOML and Rust beneath `mixins/<id>/`.
|
||||
pub files: BTreeMap<String, Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Bundle {
|
||||
pub fn read(path: &Path) -> Result<Self, String> {
|
||||
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
|
||||
let mut zip = zip::ZipArchive::new(file).map_err(|e| e.to_string())?;
|
||||
if zip.len() > 512 {
|
||||
return Err("native bundle has too many entries".into());
|
||||
}
|
||||
let mut files = BTreeMap::new();
|
||||
let mut total = 0u64;
|
||||
for i in 0..zip.len() {
|
||||
let mut entry = zip.by_index(i).map_err(|e| e.to_string())?;
|
||||
let name = entry.name().to_owned();
|
||||
if entry.is_dir() {
|
||||
continue;
|
||||
}
|
||||
if entry
|
||||
.unix_mode()
|
||||
.is_some_and(|m| m & 0o170000 != 0 && m & 0o170000 != 0o100000)
|
||||
|| name.contains('\\')
|
||||
|| name
|
||||
.split('/')
|
||||
.any(|p| p.is_empty() || p == "." || p == "..")
|
||||
|| name.starts_with('/')
|
||||
|| name.contains(':')
|
||||
{
|
||||
return Err(format!("invalid native bundle path: {name}"));
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
(&mut entry)
|
||||
.take(super::MAX_FILE + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|e| e.to_string())?;
|
||||
total += bytes.len() as u64;
|
||||
if total > super::MAX_FILE {
|
||||
return Err("native bundle expands beyond 64 MiB".into());
|
||||
}
|
||||
if files.insert(name, bytes).is_some() {
|
||||
return Err("duplicate native bundle path".into());
|
||||
}
|
||||
}
|
||||
let manifest: Manifest =
|
||||
serde_json::from_slice(files.get(MANIFEST).ok_or("missing horizon-native.json")?)
|
||||
.map_err(|e| e.to_string())?;
|
||||
manifest.check()?;
|
||||
let prefix = format!("mixins/{}/", manifest.id);
|
||||
for name in files.keys().filter(|n| n.as_str() != MANIFEST) {
|
||||
if !name.starts_with(&prefix) || !(name.ends_with(".rs") || name.ends_with(".toml")) {
|
||||
return Err(format!("unexpected native source: {name}"));
|
||||
}
|
||||
}
|
||||
let source = files
|
||||
.get(&format!("{prefix}horizon-mixin.toml"))
|
||||
.ok_or("missing horizon-mixin.toml")?;
|
||||
let source: toml::Value =
|
||||
toml::from_str(std::str::from_utf8(source).map_err(|e| e.to_string())?)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if source
|
||||
.get("mod")
|
||||
.and_then(|v| v.get("id"))
|
||||
.and_then(|v| v.as_str())
|
||||
!= Some(&manifest.id)
|
||||
|| source
|
||||
.get("mod")
|
||||
.and_then(|v| v.get("version"))
|
||||
.and_then(|v| v.as_str())
|
||||
!= Some(&manifest.version)
|
||||
|| source
|
||||
.get("compatibility")
|
||||
.and_then(|v| v.get("horizon_revision"))
|
||||
.and_then(|v| v.as_str())
|
||||
!= Some(&manifest.horizon)
|
||||
{
|
||||
return Err("native manifest and mixin identity/revision differ".into());
|
||||
}
|
||||
Ok(Self { manifest, files })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
fn manifest() -> Manifest {
|
||||
Manifest {
|
||||
schema: 1,
|
||||
id: "veinminer".into(),
|
||||
version: "0.3.0".into(),
|
||||
game: "26.3".into(),
|
||||
horizon: "a".repeat(40),
|
||||
mixins: "b".repeat(40),
|
||||
}
|
||||
}
|
||||
fn read(name: &str, extras: &[(&str, &[u8])], mixin: &str) -> Result<Bundle, String> {
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("native-bundle-{name}-{}.hnmod", std::process::id()));
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(&path).unwrap());
|
||||
let options = zip::write::SimpleFileOptions::default();
|
||||
zip.start_file(MANIFEST, options).unwrap();
|
||||
zip.write_all(&serde_json::to_vec(&manifest()).unwrap())
|
||||
.unwrap();
|
||||
zip.start_file("mixins/veinminer/horizon-mixin.toml", options)
|
||||
.unwrap();
|
||||
zip.write_all(mixin.as_bytes()).unwrap();
|
||||
for (name, bytes) in extras {
|
||||
zip.start_file(*name, options).unwrap();
|
||||
zip.write_all(bytes).unwrap();
|
||||
}
|
||||
zip.finish().unwrap();
|
||||
let result = Bundle::read(&path);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
result
|
||||
}
|
||||
fn mixin() -> String {
|
||||
format!(
|
||||
"[mod]\nid = \"veinminer\"\nversion = \"0.3.0\"\n[compatibility]\nhorizon_revision = \"{}\"\n",
|
||||
"a".repeat(40)
|
||||
)
|
||||
}
|
||||
#[test]
|
||||
fn source_bundle_carries_exact_compatibility_into_index_facts() {
|
||||
let bundle = read(
|
||||
"valid",
|
||||
&[("mixins/veinminer/hook.rs", b"fn hook() {}")],
|
||||
&mixin(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(bundle.manifest, manifest());
|
||||
let facts = bundle.manifest.facts();
|
||||
assert_eq!(
|
||||
facts.api,
|
||||
format!("native-1/{}/{}", "a".repeat(40), "b".repeat(40))
|
||||
);
|
||||
assert_eq!(facts.id.as_deref(), Some("veinminer"));
|
||||
assert!(facts.capabilities.is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn rejects_path_traversal_and_non_source_payloads() {
|
||||
for (i, name) in [
|
||||
"../escape.rs",
|
||||
"/absolute.rs",
|
||||
"mixins/other/hook.rs",
|
||||
"mixins/veinminer/code.dll",
|
||||
"mixins\\veinminer\\hook.rs",
|
||||
]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
assert!(read(&format!("bad-path-{i}"), &[(name, b"bad")], &mixin()).is_err());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn rejects_missing_or_mismatched_mixin_identity_without_panicking() {
|
||||
assert!(read("missing-mod", &[], "[unrelated]\nvalue = 1").is_err());
|
||||
assert!(
|
||||
read(
|
||||
"wrong-revision",
|
||||
&[],
|
||||
&mixin().replace(&"a".repeat(40), &"c".repeat(40))
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(read("wrong-version", &[], &mixin().replace("0.3.0", "0.2.0")).is_err());
|
||||
assert!(
|
||||
Manifest {
|
||||
horizon: "nightly".into(),
|
||||
..manifest()
|
||||
}
|
||||
.check()
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user