use provenance_macros::verifies;
use crate::wiki::model::{PageId, RecordKind};
use crate::wiki::publish::stage::{classify_route, RouteVerdict};
use crate::wiki::routes::WikiRoute;
struct Rng(u64);
impl Rng {
fn next_u64(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
let mut word = self.0;
word = (word ^ (word >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
word = (word ^ (word >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
word ^ (word >> 31)
}
fn below(&mut self, bound: usize) -> usize {
usize::try_from(self.next_u64() % bound as u64).unwrap()
}
fn pick(&mut self, choices: &[&'static str]) -> &'static str {
choices[self.below(choices.len())]
}
}
const BENIGN_SEGMENTS: &[&str] = &[
"requirements",
"rules",
"sources",
"req_one",
"rule_page_route_safety",
"index.html",
"a",
"with-dash",
"with.dot",
"...",
"....",
"..a",
"a..",
"dot..dot",
"unicode-é",
"日本語",
"%2e%2e",
"..%2f..",
"C:",
];
const HOSTILE_SEGMENTS: &[&str] = &[
".", "..", "", "/", "\\", "..\\..", "a\\b", "\\..", " ..", ".. ", "..;", "\u{1}", "\u{7f}",
"\t", "\n", "\r",
];
const BASE: [&str; 3] = ["tmp", "publish", "stage"];
fn gen_segment(rng: &mut Rng) -> &'static str {
if rng.below(5) < 3 {
rng.pick(BENIGN_SEGMENTS)
} else {
rng.pick(HOSTILE_SEGMENTS)
}
}
fn gen_route(rng: &mut Rng) -> String {
let count = rng.below(5);
let segments: Vec<&str> = (0..count).map(|_| gen_segment(rng)).collect();
let proper = if segments.is_empty() {
"/".to_string()
} else {
format!("/{}/", segments.join("/"))
};
match rng.below(6) {
3 => proper.strip_prefix('/').unwrap_or(&proper).to_string(),
4 => proper.strip_suffix('/').unwrap_or(&proper).to_string(),
5 => proper.trim_matches('/').to_string(),
_ => proper,
}
}
fn resolve<'a>(segments: &[&'a str]) -> Option<Vec<&'a str>> {
let mut walked: Vec<&str> = BASE.to_vec();
let components = segments
.iter()
.copied()
.chain(std::iter::once("index.html"))
.flat_map(|segment| segment.split(['/', '\\']));
for component in components {
match component {
"" | "." => {}
".." => {
if walked.len() <= BASE.len() {
return None;
}
walked.pop();
}
name => walked.push(name),
}
}
Some(walked)
}
fn stated_flaw(route: &str) -> Option<&'static str> {
if !route.starts_with('/') {
return Some("no leading slash");
}
if !route.ends_with('/') {
return Some("no trailing slash");
}
if route.len() > 1 && route.contains("//") {
return Some("an empty segment");
}
if route.split('/').any(|segment| segment == "..") {
return Some("a dot-dot segment");
}
None
}
#[test]
#[verifies("rule_page_route_safety", property)]
fn every_accepted_route_lands_inside_the_staging_directory() {
let mut rng = Rng(0x5eed_0101);
let mut accepted = 0;
for _ in 0..4096 {
let route = gen_route(&mut rng);
let RouteVerdict::Safe(segments) = classify_route(&route) else {
continue;
};
accepted += 1;
let rebuilt = if segments.is_empty() {
"/".to_string()
} else {
format!("/{}/", segments.join("/"))
};
assert_eq!(rebuilt, route, "accepted a reshaped route");
let landed = resolve(&segments)
.unwrap_or_else(|| panic!("accepted route climbed out of the stage: {route:?}"));
assert!(
landed.starts_with(&BASE),
"accepted route left the stage: {route:?} landed at {landed:?}"
);
assert!(
landed.len() > BASE.len(),
"accepted route wrote the stage itself: {route:?}"
);
assert_eq!(landed.last(), Some(&"index.html"));
}
assert!(
accepted >= 512,
"only {accepted} generated routes were accepted; the property proves too little"
);
}
#[test]
#[verifies("rule_page_route_safety", property)]
fn every_route_carrying_a_named_flaw_is_refused() {
let mut rng = Rng(0x5eed_0202);
let mut flawed = 0;
for _ in 0..4096 {
let route = gen_route(&mut rng);
let Some(flaw) = stated_flaw(&route) else {
continue;
};
flawed += 1;
assert!(
!matches!(classify_route(&route), RouteVerdict::Safe(_)),
"accepted a route with {flaw}: {route:?}"
);
}
assert!(
flawed >= 512,
"only {flawed} generated routes carried a named flaw; the property proves too little"
);
}
#[test]
#[verifies("rule_page_route_safety", examples)]
fn refuses_the_traversal_shapes_the_staging_tests_drive() {
for route in [
"/../escaped/",
"/requirements/../escape/",
"/a/../../escaped/",
"/./escaped/",
] {
assert!(
matches!(classify_route(route), RouteVerdict::UnsafeSegment(_)),
"accepted a traversal route: {route:?}"
);
}
assert_eq!(
WikiRoute::Record(&PageId::new(RecordKind::Requirement, "../escape")).path(),
"/requirements/../escape/"
);
}
#[test]
#[verifies("rule_page_route_safety", examples)]
fn refuses_routes_that_are_not_bounded_by_slashes() {
assert_eq!(classify_route(""), RouteVerdict::Unbounded);
assert_eq!(classify_route("requirements/"), RouteVerdict::Unbounded);
assert_eq!(classify_route("/requirements"), RouteVerdict::Unbounded);
assert_eq!(classify_route("requirements"), RouteVerdict::Unbounded);
}
#[test]
#[verifies("rule_page_route_safety", examples)]
fn refuses_segments_that_hide_further_path_structure() {
assert_eq!(
classify_route("/a\\..\\b/"),
RouteVerdict::UnsafeSegment("a\\..\\b")
);
assert_eq!(
classify_route("/a/\u{0}/b/"),
RouteVerdict::UnsafeSegment("\u{0}")
);
assert_eq!(classify_route("/a//b/"), RouteVerdict::UnsafeSegment(""));
assert_eq!(classify_route("//"), RouteVerdict::UnsafeSegment(""));
}
#[test]
#[verifies("rule_page_route_safety", examples)]
fn accepts_every_route_the_wiki_mints_for_a_page() {
for route in [
WikiRoute::Index.path(),
WikiRoute::Domains.path(),
WikiRoute::Search.path(),
WikiRoute::Decisions.path(),
WikiRoute::Unfinished.path(),
WikiRoute::Record(&PageId::new(RecordKind::Rule, "rule_one")).path(),
WikiRoute::Record(&PageId::new(RecordKind::Source, "src_one")).path(),
] {
assert!(
matches!(classify_route(&route), RouteVerdict::Safe(_)),
"the wiki mints a page route the publisher refuses: {route:?}"
);
}
assert_eq!(classify_route("/"), RouteVerdict::Safe(Vec::new()));
assert_eq!(
classify_route("/rules/rule_one/"),
RouteVerdict::Safe(vec!["rules", "rule_one"])
);
assert_eq!(
classify_route(&WikiRoute::Stylesheet.path()),
RouteVerdict::Unbounded
);
}