feat(tests): add property-based tests for hex colour parsing and repository upload filename sanitization to enhance robustness

This commit is contained in:
Ivan committed 2026-04-23 13:17:26 -05:00
1 parent 5b81f24c37
commit a345321047
5 files changed
+164

No files matched your search

@@ -0,0 +1,46 @@
# SPDX-License-Identifier: 0BSD
"""Property tests for hex colour parsing."""
import pytest
from hypothesis import given, settings
from hypothesis import strategies as st
from meshchatx.src.backend.colour_utils import ColourUtils
@settings(max_examples=200, deadline=None)
@given(t=st.text(max_size=200))
def test_hex_colour_only_value_error_or_returns_bytes(t):
try:
out = ColourUtils.hex_colour_to_byte_array(t)
except ValueError:
return
except Exception as e:
raise AssertionError(f"unexpected exception: {type(e).__name__}: {e}") from e
assert isinstance(out, bytes)
@settings(max_examples=150, deadline=None)
@given(
h=st.from_regex(r"[0-9a-fA-F]{2,64}", fullmatch=True).filter(
lambda x: len(x) % 2 == 0
)
)
def test_hex_colour_even_length_hex_round_trip(h):
out = ColourUtils.hex_colour_to_byte_array(h)
out_hash = ColourUtils.hex_colour_to_byte_array("#" + h)
assert out == out_hash
assert len(out) == len(h) // 2
@settings(max_examples=80, deadline=None)
@given(
h=st.from_regex(r"[0-9a-fA-F]{1,63}", fullmatch=True).filter(
lambda x: len(x) % 2 == 1
)
)
def test_hex_colour_odd_length_raises(h):
with pytest.raises(ValueError):
ColourUtils.hex_colour_to_byte_array(h)
@@ -0,0 +1,60 @@
# SPDX-License-Identifier: 0BSD
"""Property tests for repository upload filename sanitization."""
import os
import re
import tempfile
from hypothesis import given, settings
from hypothesis import strategies as st
from meshchatx.src.backend.repository_server_manager import (
RepositoryServerManager,
_safe_any_upload_filename,
)
_SAFE_RE = re.compile(r"[A-Za-z0-9._+\-]+")
@settings(max_examples=250, deadline=None)
@given(name=st.text(max_size=400))
def test_safe_any_upload_filename_never_raises(name):
out = _safe_any_upload_filename(name)
if out is None:
return
assert isinstance(out, str)
assert out == os.path.basename(name)
assert out == name
assert ".." not in out
assert _SAFE_RE.fullmatch(out)
@settings(max_examples=200, deadline=None)
@given(
base=st.from_regex(r"[A-Za-z0-9._+\-]{1,120}", fullmatch=True).filter(
lambda b: b not in (".", "..") and ".." not in b
),
)
def test_save_upload_roundtrip_for_generated_safe_names(base):
with tempfile.TemporaryDirectory() as td:
mgr = RepositoryServerManager(td)
ok, err = mgr.save_upload(base, b"\x00\x01\x02")
assert ok and err is None
dest = os.path.join(td, "repository-server", "uploads", base)
assert dest == os.path.realpath(dest)
assert os.path.isfile(dest)
assert os.path.dirname(dest) == os.path.join(td, "repository-server", "uploads")
@settings(max_examples=120, deadline=None)
@given(
prefix=st.text(max_size=80),
base=st.from_regex(r"[A-Za-z0-9._+\-]{1,40}", fullmatch=True),
)
def test_save_upload_rejects_path_traversal_prefix(prefix, base):
poison = prefix + "../" + base
with tempfile.TemporaryDirectory() as td:
mgr = RepositoryServerManager(td)
ok, err = mgr.save_upload(poison, b"x")
assert not ok
+15
View File
@@ -41,4 +41,19 @@ describe("safeExternalUrl", () => {
}
}
});
it("fuzz: full BMP code units do not throw and only yield safe schemes", () => {
for (let i = 0; i < 250; i++) {
let s = "";
const len = Math.floor(Math.random() * 400);
for (let j = 0; j < len; j++) {
s += String.fromCharCode(Math.floor(Math.random() * 65536));
}
expect(() => normalizeExternalUrlForOpen(s)).not.toThrow();
const o = normalizeExternalUrlForOpen(s);
if (o !== null) {
expect(o.startsWith("http://") || o.startsWith("https://") || o.startsWith("mailto:")).toBe(true);
}
}
});
});
+23
View File
@@ -57,4 +57,27 @@ describe("shellPathGuard", () => {
fs.unlinkSync(p);
}
});
it("fuzz: paths under default storage stay allowed", () => {
for (let i = 0; i < 200; i++) {
const seg = Array.from({ length: 10 }, () => String.fromCharCode(Math.floor(Math.random() * 94) + 33)).join(
""
);
const p = path.join(storage, "fuzz-shell", seg);
expect(() => isAllowedShellPath(p, ctx)).not.toThrow();
expect(isAllowedShellPath(p, ctx)).toBe(true);
}
});
it("fuzz: random absolute-looking paths return boolean without throw", () => {
for (let i = 0; i < 200; i++) {
let s = process.platform === "win32" ? "C:\\" : "/";
const n = Math.floor(Math.random() * 24) + 4;
for (let j = 0; j < n; j++) {
s += String.fromCharCode(Math.floor(Math.random() * 96) + 32);
}
expect(() => isAllowedShellPath(s, ctx)).not.toThrow();
expect(typeof isAllowedShellPath(s, ctx)).toBe("boolean");
}
});
});
+20
View File
@@ -178,4 +178,24 @@ describe("LinkUtils.js", () => {
expect(LinkUtils.httpUrlHrefOrNull("file:///etc/passwd")).toBeNull();
});
});
describe("fuzzing robustness", () => {
it("httpUrlHrefOrNull and renderAllLinks tolerate random BMP strings", () => {
for (let i = 0; i < 300; i++) {
let s = "";
const len = Math.floor(Math.random() * 600);
for (let j = 0; j < len; j++) {
s += String.fromCharCode(Math.floor(Math.random() * 65536));
}
expect(() => LinkUtils.httpUrlHrefOrNull(s)).not.toThrow();
expect(() => LinkUtils.renderAllLinks(s)).not.toThrow();
const h = LinkUtils.httpUrlHrefOrNull(s);
if (h !== null) {
expect(h.startsWith("http://") || h.startsWith("https://")).toBe(true);
}
const rendered = LinkUtils.renderAllLinks(s);
expect(rendered.toLowerCase()).not.toContain("<script");
}
});
});
});