use impeccable_browser::html_snapshot::{HtmlSnapshot, SnapshotSelection};
use std::{
fs,
path::PathBuf,
time::{SystemTime, UNIX_EPOCH},
};
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
let root = std::env::temp_dir().join(format!(
"capture-snapshot-{}-{}-{}",
std::process::id(),
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(root.join("assets")).unwrap();
fs::create_dir_all(root.join(".impeccable")).unwrap();
for (name, bytes) in [
("index.html", "
"),
("other.html", "other page"),
("assets/art.png", "asset bytes"),
(".impeccable/spec.json", "{}"),
(".impeccable/comp.png", "comp bytes"),
(".env", "private"),
] {
fs::write(root.join(name), bytes).unwrap();
}
Self(root)
}
fn selection(&self) -> SnapshotSelection {
SnapshotSelection {
root: self.0.clone(),
entry: "index.html".into(),
served: vec!["index.html".into(), "assets/art.png".into()],
bound: vec![
".impeccable/spec.json".into(),
".impeccable/comp.png".into(),
],
}
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn snapshot_freezes_bytes_and_detects_every_changed_input() {
let f = Fixture::new();
let original = HtmlSnapshot::freeze(f.selection()).unwrap();
let mut reordered = f.selection();
reordered.served.reverse();
reordered.bound.reverse();
assert_eq!(
original.digest(),
HtmlSnapshot::freeze(reordered).unwrap().digest()
);
for name in [
"index.html",
"assets/art.png",
".impeccable/spec.json",
".impeccable/comp.png",
] {
let before = fs::read(f.0.join(name)).unwrap();
fs::write(f.0.join(name), b"changed").unwrap();
assert!(original.verify_current().is_err(), "{name}");
assert_eq!(original.bytes(name).unwrap(), before);
fs::write(f.0.join(name), before).unwrap();
original.verify_current().unwrap();
}
}
#[test]
fn routes_are_explicit_and_never_expose_bound_private_files() {
let f = Fixture::new();
let s = HtmlSnapshot::freeze(f.selection()).unwrap();
assert_eq!(
s.serve_path("/index.html?cache=2"),
Some("index.html".into())
);
assert_eq!(
s.serve_path("/assets/art.png"),
Some("assets/art.png".into())
);
for route in [
"/",
"/other.html",
"/.env",
"/.impeccable/comp.png",
"/../index.html",
"/assets/../index.html",
"/%2e%2e/index.html",
"/assets%2fart.png",
"/assets\\art.png",
"http://example.com/index.html",
] {
assert!(s.serve_path(route).is_none(), "{route}");
}
let mut selection = f.selection();
selection.served.push(".env".into());
assert!(HtmlSnapshot::freeze(selection).is_err());
}
#[test]
fn snapshot_rejects_traversal_symlinks_and_non_html_entries() {
let f = Fixture::new();
for bad in [
"../index.html",
"/index.html",
"assets/../index.html",
"assets/art.png",
] {
let mut selection = f.selection();
selection.entry = bad.into();
assert!(HtmlSnapshot::freeze(selection).is_err(), "{bad}");
}
#[cfg(unix)]
{
std::os::unix::fs::symlink(f.0.join("assets"), f.0.join("linked")).unwrap();
let mut selection = f.selection();
selection.served.push("linked/art.png".into());
assert!(HtmlSnapshot::freeze(selection).is_err());
let s = HtmlSnapshot::freeze(f.selection()).unwrap();
fs::rename(f.0.join("assets"), f.0.join("original-assets")).unwrap();
std::os::unix::fs::symlink(f.0.join("original-assets"), f.0.join("assets")).unwrap();
assert!(s.verify_current().is_err());
}
}
#[test]
fn snapshot_server_serves_frozen_bytes_and_checks_host() {
use std::{
io::{Read, Write},
net::TcpStream,
sync::Arc,
};
let f = Fixture::new();
let snapshot = Arc::new(HtmlSnapshot::freeze(f.selection()).unwrap());
let server = snapshot.serve().unwrap();
let url = server.entry_url();
let host = url
.strip_prefix("http://")
.unwrap()
.split('/')
.next()
.unwrap();
let get = |path: &str, request_host: &str| {
let mut stream = TcpStream::connect(host).unwrap();
stream
.set_read_timeout(Some(std::time::Duration::from_secs(5)))
.unwrap();
write!(
stream,
"GET {path} HTTP/1.1\r\nHost: {request_host}\r\nConnection: close\r\n\r\n"
)
.unwrap();
let mut response = Vec::new();
stream.read_to_end(&mut response).unwrap();
response
};
fs::write(f.0.join("assets/art.png"), b"new bytes").unwrap();
let actual = get("/assets/art.png", host);
assert!(actual.ends_with(b"asset bytes"));
assert!(
String::from_utf8_lossy(&actual)
.contains("Cache-Control: private, max-age=3600, immutable")
);
assert!(String::from_utf8_lossy(&actual).starts_with("HTTP/1.1 200 OK"));
for (path, h) in [
("/.env", host),
("/.impeccable/spec.json", host),
("/other.html", host),
("/index.html", "attacker.example"),
("/assets/../index.html", host),
] {
assert!(String::from_utf8_lossy(&get(path, h)).starts_with("HTTP/1.1 404"));
}
assert!(snapshot.verify_current().is_err());
}
#[test]
fn snapshot_server_delivers_large_binary_response_completely() {
use std::{
io::{Read, Write},
net::TcpStream,
sync::Arc,
};
let f = Fixture::new();
let payload: Vec = (0..3 * 1024 * 1024).map(|n| (n % 251) as u8).collect();
fs::write(f.0.join("assets/art.png"), &payload).unwrap();
let snapshot = Arc::new(HtmlSnapshot::freeze(f.selection()).unwrap());
let server = snapshot.serve().unwrap();
let url = server.entry_url();
let host = url
.strip_prefix("http://")
.unwrap()
.split('/')
.next()
.unwrap();
let mut stream = TcpStream::connect(host).unwrap();
stream
.set_read_timeout(Some(std::time::Duration::from_secs(5)))
.unwrap();
write!(
stream,
"GET /assets/art.png HTTP/1.1\r\nHost: {host}\r\n\r\n"
)
.unwrap();
let mut response = Vec::new();
stream.read_to_end(&mut response).unwrap();
let body = response.windows(4).position(|b| b == b"\r\n\r\n").unwrap() + 4;
assert_eq!(
response.len() - body,
payload.len(),
"large response was truncated"
);
assert_eq!(&response[body..], payload);
}