makepad/platform/tests/resource_resolver.rs

143 lines
6 KiB
Rust

//! The path-escape suite for `ResourceResolver` (KERNELS.md §5.2, P0-S):
//! every way a document's resource name could reach outside its root is
//! refused, and decoder budgets refuse overflowing or oversized images
//! before anything is allocated.
use makepad_platform::resource_resolver::*;
use std::path::PathBuf;
fn fresh_dir(name: &str) -> PathBuf {
let dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("resource_resolver").join(name);
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// root/doc/{a.txt, sub/b.txt} and root/secret.txt beside the document root.
fn fixture(name: &str) -> (PathBuf, ResourceResolver) {
let base = fresh_dir(name);
let doc = base.join("doc");
std::fs::create_dir_all(doc.join("sub")).unwrap();
std::fs::write(doc.join("a.txt"), b"alpha").unwrap();
std::fs::write(doc.join("sub/b.txt"), b"beta").unwrap();
std::fs::write(base.join("secret.txt"), b"secret").unwrap();
let resolver = ResourceResolver::new(&doc).unwrap();
(base, resolver)
}
#[test]
fn plain_relative_names_resolve_beneath_the_root() {
let (_base, r) = fixture("plain");
assert_eq!(r.read("a.txt", 100).unwrap(), b"alpha");
assert_eq!(r.read("sub/b.txt", 100).unwrap(), b"beta");
assert_eq!(r.read("./sub/./b.txt", 100).unwrap(), b"beta");
assert_eq!(r.read("sub//b.txt", 100).unwrap(), b"beta");
assert!(r.resolve("sub/b.txt").unwrap().starts_with(r.root()));
}
#[test]
fn escapes_are_refused() {
let (base, r) = fixture("escapes");
let secret = base.join("secret.txt");
for name in [
"../secret.txt",
"sub/../../secret.txt",
"sub/../a.txt", // `..` is refused even when it would stay inside
"..",
secret.to_str().unwrap(),
"/etc/passwd",
"//etc/passwd",
"sub\\..\\..\\secret.txt",
"C:secret.txt",
"C:/Windows/win.ini",
"a.txt:stream",
"a.txt\0.png",
"",
".",
"./",
] {
let err = r.resolve(name).expect_err(name);
assert!(matches!(err, ResourceError::InvalidPath(_)), "{name:?}: {err}");
}
let long = "a/".repeat(600) + "x";
assert!(matches!(r.resolve(&long), Err(ResourceError::InvalidPath(_))));
}
#[cfg(unix)]
#[test]
fn symlinks_are_never_followed() {
let (base, r) = fixture("symlinks");
let doc = r.root().to_path_buf();
// A link to a file outside the root.
std::os::unix::fs::symlink(base.join("secret.txt"), doc.join("out.txt")).unwrap();
// A link to a directory outside the root, then a name through it.
std::os::unix::fs::symlink(&base, doc.join("up")).unwrap();
// A link that stays inside the root is refused too: links are not
// followed at all, so no check depends on where they point.
std::os::unix::fs::symlink(doc.join("a.txt"), doc.join("alias.txt")).unwrap();
std::os::unix::fs::symlink(doc.join("sub"), doc.join("subalias")).unwrap();
for name in ["out.txt", "up/secret.txt", "up", "alias.txt", "subalias/b.txt"] {
let err = r.read(name, 100).expect_err(name);
assert!(matches!(err, ResourceError::Symlink(_)), "{name}: {err}");
}
}
#[test]
fn missing_files_directories_and_oversize_reads_fail_cleanly() {
let (_base, r) = fixture("reads");
assert!(matches!(r.read("nope.txt", 100), Err(ResourceError::Io(_))));
assert!(matches!(r.read("sub", 100), Err(ResourceError::InvalidPath(_))));
assert!(matches!(r.read("a.txt", 4), Err(ResourceError::Budget(_))));
assert_eq!(r.read("a.txt", 5).unwrap(), b"alpha");
}
#[test]
fn asset_ids_follow_the_grammar() {
for ok in ["abc", "ABC-123_x", "tex.v2", "a", &"x".repeat(128)] {
assert!(validate_asset_id(ok).is_ok(), "{ok}");
}
for bad in [
"", "../x", "..", ".hidden", "a/b", "a\\b", "a..b", "a b", "é", "a:b", "a\0b", "/abs", &"x".repeat(129),
] {
assert!(matches!(validate_asset_id(bad), Err(ResourceError::InvalidAssetId(_))), "{bad:?}");
}
}
#[test]
fn decode_budgets_refuse_before_allocating() {
let mut budget = DecodeBudget::new(1000);
assert_eq!(budget.charge_image(10, 10, 4, "img").unwrap(), 400);
assert_eq!(budget.remaining(), 600);
// Over the remaining budget: refused, nothing taken.
assert!(matches!(budget.charge_image(20, 10, 4, "img"), Err(ResourceError::Budget(_))));
assert_eq!(budget.used(), 400);
// Overflowing sizes are errors, never wrapped into small numbers.
assert!(DecodeBudget::image_bytes(1 << 33, 1 << 31, 4).is_err());
assert!(DecodeBudget::image_bytes(u64::MAX, 2, 1).is_err());
assert!(budget.charge_image(u64::MAX, u64::MAX, 16, "bomb").is_err());
budget.release(400);
assert_eq!(budget.used(), 0);
assert!(budget.charge(1000, "all").is_ok());
assert!(budget.charge(1, "one more").is_err());
}
#[test]
fn absolute_paths_are_read_only_inside_trusted_roots() {
let (base, r) = fixture("trusted");
let roots = [r.clone()];
// Inside the root, spelled with `..` through the root itself: fine.
let spelled = base.join("doc/sub/../a.txt");
assert_eq!(ResourceResolver::read_within(&roots, &spelled, 100).unwrap(), b"alpha");
assert_eq!(ResourceResolver::read_within(&roots, &base.join("doc/sub/b.txt"), 100).unwrap(), b"beta");
// Outside every root, or relative: refused.
let err = ResourceResolver::read_within(&roots, &base.join("secret.txt"), 100).unwrap_err();
assert!(matches!(err, ResourceError::Escape(_)), "{err}");
assert!(ResourceResolver::read_within(&roots, &base.join("doc/../secret.txt"), 100).is_err());
assert!(ResourceResolver::read_within(&roots, std::path::Path::new("a.txt"), 100).is_err());
assert!(ResourceResolver::read_within(&[], &base.join("doc/a.txt"), 100).is_err());
#[cfg(unix)]
{
// A link inside the root pointing out resolves outside: refused.
std::os::unix::fs::symlink(base.join("secret.txt"), r.root().join("out.txt")).unwrap();
assert!(ResourceResolver::read_within(&roots, &r.root().join("out.txt"), 100).is_err());
}
}