use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use index_dom::{IndexManifest, parse_index_manifest};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use url::Url;
const COMPATIBILITY_PACK_VERSION: &str = "index.pack/v1";
const COMPATIBILITY_PACK_MAX_BYTES: usize = 64 * 1024;
const COMPATIBILITY_PACK_MAX_RULES: usize = 128;
const COMPATIBILITY_PACK_POLICY_FILE: &str = "compat-pack-runtime.conf";
const COMPATIBILITY_PACK_USER_DIR: &str = "compat-packs/user";
const COMPATIBILITY_PACK_TRUSTED_DIR: &str = "compat-packs/trusted";
const COMPATIBILITY_PACK_ROLLBACK_DIR: &str = "compat-packs/rollback";
const BUILTIN_EMPTY_PACK: &str = include_str!("../assets/compat/default.pack.json");
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PackSource {
User,
Trusted,
BuiltIn,
}
impl PackSource {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::User => "user",
Self::Trusted => "trusted",
Self::BuiltIn => "built-in",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CompatibilityPackPolicy {
pub enabled: bool,
pub allow_user: bool,
pub allow_trusted: bool,
}
impl Default for CompatibilityPackPolicy {
fn default() -> Self {
Self {
enabled: true,
allow_user: true,
allow_trusted: true,
}
}
}
#[derive(Debug, Clone)]
struct LoadedPack {
id: String,
source: PackSource,
rules: Vec<LoadedRule>,
}
#[derive(Debug, Clone)]
struct LoadedRule {
host: String,
path_prefix: String,
manifest: IndexManifest,
}
#[derive(Debug, Clone)]
pub struct SelectedPackManifest {
pub manifest: IndexManifest,
pub source: PackSource,
pub pack_id: String,
pub rule_host: String,
pub rule_path_prefix: String,
}
#[derive(Debug, Deserialize)]
struct RawCompatibilityPack {
version: String,
id: String,
#[serde(default)]
rules: Vec<RawCompatibilityRule>,
}
#[derive(Debug, Deserialize)]
struct RawCompatibilityRule {
host: String,
#[serde(default)]
path_prefix: Option<String>,
#[serde(default)]
source_url: Option<String>,
manifest: serde_json::Value,
}
pub fn load_pack_policy(config_dir: &str) -> Result<CompatibilityPackPolicy, String> {
let path = Path::new(config_dir).join(COMPATIBILITY_PACK_POLICY_FILE);
let input = match fs::read_to_string(&path) {
Ok(value) => value,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(CompatibilityPackPolicy::default());
}
Err(error) => {
return Err(format!(
"failed to read compatibility pack policy {}: {error}",
path.display()
));
}
};
let mut policy = CompatibilityPackPolicy::default();
for (line_number, line) in input.lines().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let Some((raw_key, raw_value)) = trimmed.split_once('=') else {
return Err(format!(
"invalid compatibility pack policy line {}: expected key=value",
line_number + 1
));
};
let key = raw_key.trim().to_ascii_lowercase();
let value = parse_bool_config_value(raw_value.trim(), line_number + 1)?;
match key.as_str() {
"enabled" => policy.enabled = value,
"allow_user" => policy.allow_user = value,
"allow_trusted" => policy.allow_trusted = value,
_ => {
return Err(format!(
"unsupported compatibility pack policy key at line {}: {}",
line_number + 1,
raw_key.trim()
));
}
}
}
Ok(policy)
}
pub fn lint_pack_file(pack_path: &str, page_url: &str) -> Result<String, String> {
let bytes =
fs::read(pack_path).map_err(|error| format!("failed to read {pack_path}: {error}"))?;
let pack = parse_pack_bytes(&bytes, PackSource::User, pack_path)?;
let normalized = normalize_url(page_url)?;
let selected = select_rule_for_url(&pack.rules, &normalized);
Ok(format!(
"index-compat-pack-lint-v1\npack: {pack_path}\npage_url: {normalized}\npack_id: {}\nrules: {}\nmatch: {}\nresult: pass",
pack.id,
pack.rules.len(),
selected
.map(|rule| format!("host={} path_prefix={}", rule.host, rule.path_prefix))
.unwrap_or_else(|| "none".to_owned())
))
}
pub fn inspect_runtime_for_url(config_dir: &str, page_url: &str) -> Result<String, String> {
let normalized = normalize_url(page_url)?;
let policy = load_pack_policy(config_dir)?;
if !policy.enabled {
return Ok(format!(
"index-compat-pack-runtime-v1\nurl: {normalized}\npolicy: enabled=false allow_user={} allow_trusted={}\nselected: none\nresult: pass",
policy.allow_user, policy.allow_trusted
));
}
let packs = load_runtime_packs(config_dir, policy)?;
let selected = packs
.iter()
.find_map(|pack| select_rule_for_url(&pack.rules, &normalized).map(|rule| (pack, rule)));
Ok(format!(
"index-compat-pack-runtime-v1\nurl: {normalized}\npolicy: enabled=true allow_user={} allow_trusted={}\nloaded_packs: {}\nselected: {}\nresult: pass",
policy.allow_user,
policy.allow_trusted,
packs.len(),
selected
.map(|(pack, rule)| format!(
"{}:{} host={} path_prefix={}",
pack.source.as_str(),
pack.id,
rule.host,
rule.path_prefix
))
.unwrap_or_else(|| "none".to_owned())
))
}
pub fn select_runtime_manifest_for_url(
config_dir: &str,
page_url: &str,
) -> Result<Option<SelectedPackManifest>, String> {
let normalized = normalize_url(page_url)?;
let policy = load_pack_policy(config_dir)?;
if !policy.enabled {
return Ok(None);
}
let packs = load_runtime_packs(config_dir, policy)?;
for pack in &packs {
if let Some(rule) = select_rule_for_url(&pack.rules, &normalized) {
return Ok(Some(SelectedPackManifest {
manifest: rule.manifest.clone(),
source: pack.source,
pack_id: pack.id.clone(),
rule_host: rule.host.clone(),
rule_path_prefix: rule.path_prefix.clone(),
}));
}
}
Ok(None)
}
fn load_runtime_packs(
config_dir: &str,
policy: CompatibilityPackPolicy,
) -> Result<Vec<LoadedPack>, String> {
let mut packs = Vec::new();
if policy.allow_user {
let user_dir = Path::new(config_dir).join(COMPATIBILITY_PACK_USER_DIR);
packs.extend(load_pack_dir(&user_dir, PackSource::User)?);
}
if policy.allow_trusted {
let trusted_dir = Path::new(config_dir).join(COMPATIBILITY_PACK_TRUSTED_DIR);
packs.extend(load_pack_dir(&trusted_dir, PackSource::Trusted)?);
}
packs.push(parse_pack_bytes(
BUILTIN_EMPTY_PACK.as_bytes(),
PackSource::BuiltIn,
"built-in/default.pack.json",
)?);
Ok(packs)
}
fn load_pack_dir(path: &Path, source: PackSource) -> Result<Vec<LoadedPack>, String> {
let mut entries = Vec::new();
let directory = match fs::read_dir(path) {
Ok(read_dir) => read_dir,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(entries),
Err(error) => {
return Err(format!(
"failed to read compatibility pack directory {}: {error}",
path.display()
));
}
};
let mut files = Vec::new();
for entry in directory {
let entry = entry.map_err(|error| {
format!(
"failed to enumerate compatibility pack directory {}: {error}",
path.display()
)
})?;
let file_type = entry.file_type().map_err(|error| {
format!(
"failed to inspect compatibility pack entry {}: {error}",
entry.path().display()
)
})?;
if file_type.is_file() {
files.push(entry.path());
}
}
files.sort();
for file in files {
let bytes = fs::read(&file).map_err(|error| {
format!(
"failed to read compatibility pack {}: {error}",
file.display()
)
})?;
let origin = file.display().to_string();
entries.push(parse_pack_bytes(&bytes, source, &origin)?);
}
Ok(entries)
}
fn parse_pack_bytes(bytes: &[u8], source: PackSource, origin: &str) -> Result<LoadedPack, String> {
if bytes.len() > COMPATIBILITY_PACK_MAX_BYTES {
return Err(format!(
"compatibility pack {} exceeds limit: {} bytes (max {})",
origin,
bytes.len(),
COMPATIBILITY_PACK_MAX_BYTES
));
}
let input = std::str::from_utf8(bytes)
.map_err(|error| format!("compatibility pack {} is not UTF-8: {error}", origin))?;
let raw = serde_json::from_str::<RawCompatibilityPack>(input)
.map_err(|error| format!("compatibility pack {} is invalid JSON: {error}", origin))?;
if raw.version != COMPATIBILITY_PACK_VERSION {
return Err(format!(
"compatibility pack {} uses unsupported version: {}",
origin, raw.version
));
}
let id = validate_pack_id(&raw.id)
.map_err(|error| format!("compatibility pack {} has invalid id: {error}", origin))?;
if raw.rules.len() > COMPATIBILITY_PACK_MAX_RULES {
return Err(format!(
"compatibility pack {} has too many rules: {} (max {})",
origin,
raw.rules.len(),
COMPATIBILITY_PACK_MAX_RULES
));
}
let rules = raw
.rules
.iter()
.enumerate()
.map(|(index, rule)| parse_rule(index, &id, rule))
.collect::<Result<Vec<_>, _>>()?;
Ok(LoadedPack { id, source, rules })
}
fn parse_rule(
index: usize,
pack_id: &str,
raw: &RawCompatibilityRule,
) -> Result<LoadedRule, String> {
let host = validate_rule_host(&raw.host).map_err(|error| {
format!(
"pack {} rule {} invalid host {}: {error}",
pack_id,
index + 1,
raw.host
)
})?;
let path_prefix =
validate_path_prefix(raw.path_prefix.as_deref().unwrap_or("/")).map_err(|error| {
format!(
"pack {} rule {} invalid path_prefix {}: {error}",
pack_id,
index + 1,
raw.path_prefix.as_deref().unwrap_or("/")
)
})?;
let source_url = raw
.source_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("https://{host}/.well-known/index.idx"));
let page_url = format!("https://{host}{path_prefix}");
let manifest_json = serde_json::to_string(&raw.manifest).map_err(|error| {
format!(
"pack {pack_id} rule {} manifest serialization failed: {error}",
index + 1
)
})?;
let manifest =
parse_index_manifest(&manifest_json, &source_url, &page_url).map_err(|error| {
format!(
"pack {} rule {} manifest is invalid: {}",
pack_id,
index + 1,
error
)
})?;
Ok(LoadedRule {
host,
path_prefix,
manifest,
})
}
fn validate_pack_id(value: &str) -> Result<String, String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err("id must not be empty".to_owned());
}
if trimmed.len() > 128 {
return Err("id exceeds 128 characters".to_owned());
}
if !trimmed
.chars()
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-'))
{
return Err("id may only use [A-Za-z0-9._-]".to_owned());
}
Ok(trimmed.to_owned())
}
fn validate_rule_host(value: &str) -> Result<String, String> {
let host = value.trim().to_ascii_lowercase();
if host.is_empty() {
return Err("host must not be empty".to_owned());
}
if host.len() > 253 {
return Err("host exceeds 253 characters".to_owned());
}
if !host
.chars()
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '.' | '-'))
{
return Err("host may only use [A-Za-z0-9.-]".to_owned());
}
if !host.contains('.') {
return Err("host must contain a dot".to_owned());
}
Ok(host)
}
fn validate_path_prefix(value: &str) -> Result<String, String> {
let prefix = value.trim();
if prefix.is_empty() {
return Err("path_prefix must not be empty".to_owned());
}
if !prefix.starts_with('/') {
return Err("path_prefix must start with '/'".to_owned());
}
if prefix.contains('*') || prefix.contains(' ') {
return Err("path_prefix may not contain '*' or spaces".to_owned());
}
if prefix.len() > 256 {
return Err("path_prefix exceeds 256 characters".to_owned());
}
Ok(prefix.to_owned())
}
fn parse_bool_config_value(value: &str, line_number: usize) -> Result<bool, String> {
match value.trim().to_ascii_lowercase().as_str() {
"true" => Ok(true),
"false" => Ok(false),
_ => Err(format!(
"invalid compatibility pack policy boolean at line {}: {}",
line_number, value
)),
}
}
fn normalize_url(input: &str) -> Result<String, String> {
let trimmed = input.trim();
if trimmed.contains("://") {
return Ok(trimmed.to_owned());
}
Ok(format!("https://{trimmed}"))
}
fn select_rule_for_url<'a>(rules: &'a [LoadedRule], url: &str) -> Option<&'a LoadedRule> {
let (host, path) = url_host_path(url)?;
rules.iter().find(|rule| {
rule.host.eq_ignore_ascii_case(host.as_str())
&& (path == rule.path_prefix
|| path
.strip_prefix(&rule.path_prefix)
.is_some_and(|rest| rest.starts_with('/') || rest.is_empty())
|| (rule.path_prefix == "/" && path.starts_with('/')))
})
}
fn url_host_path(url: &str) -> Option<(String, String)> {
let parsed = Url::parse(url).ok()?;
let host = parsed.host_str()?.to_ascii_lowercase();
let path = if parsed.path().is_empty() {
"/".to_owned()
} else {
parsed.path().to_owned()
};
Some((host, path))
}
pub fn install_pack_file(
config_dir: &str,
pack_path: &str,
source: PackSource,
) -> Result<PathBuf, String> {
let bytes =
fs::read(pack_path).map_err(|error| format!("failed to read {pack_path}: {error}"))?;
let pack = parse_pack_bytes(&bytes, source, pack_path)?;
let relative_dir = match source {
PackSource::User => COMPATIBILITY_PACK_USER_DIR,
PackSource::Trusted => COMPATIBILITY_PACK_TRUSTED_DIR,
PackSource::BuiltIn => {
return Err("cannot install into built-in source".to_owned());
}
};
let directory = Path::new(config_dir).join(relative_dir);
fs::create_dir_all(&directory).map_err(|error| {
format!(
"failed to create compatibility pack directory {}: {error}",
directory.display()
)
})?;
let target = directory.join(format!("{}.pack.json", pack.id));
if let Ok(previous) = fs::read(&target) {
create_rollback_snapshot(config_dir, &pack.id, source, &previous)?;
}
fs::write(&target, bytes).map_err(|error| {
format!(
"failed to write compatibility pack {}: {error}",
target.display()
)
})?;
Ok(target)
}
pub fn list_runtime_pack_files(config_dir: &str) -> Vec<String> {
let mut files = Vec::new();
for (source, relative) in [
("user", COMPATIBILITY_PACK_USER_DIR),
("trusted", COMPATIBILITY_PACK_TRUSTED_DIR),
] {
let directory = Path::new(config_dir).join(relative);
if let Ok(entries) = fs::read_dir(directory) {
let mut paths = entries
.flatten()
.filter_map(|entry| {
entry
.file_type()
.ok()
.filter(|file_type| file_type.is_file())
.map(|_| format!("{}\t{}", source, entry.path().display()))
})
.collect::<Vec<_>>();
paths.sort();
files.extend(paths);
}
}
files
}
pub fn rollback_pack_file(config_dir: &str, pack_id: &str) -> Result<PathBuf, String> {
let rollback_dir = Path::new(config_dir)
.join(COMPATIBILITY_PACK_ROLLBACK_DIR)
.join(pack_id);
let mut snapshots = Vec::new();
let entries = fs::read_dir(&rollback_dir).map_err(|error| {
format!(
"failed to read rollback directory {}: {error}",
rollback_dir.display()
)
})?;
for entry in entries {
let entry = entry.map_err(|error| {
format!(
"failed to enumerate rollback directory {}: {error}",
rollback_dir.display()
)
})?;
if entry
.file_type()
.map(|kind| kind.is_file())
.unwrap_or(false)
{
snapshots.push(entry.path());
}
}
snapshots.sort();
let Some(snapshot) = snapshots.pop() else {
return Err(format!("no rollback snapshots available for {pack_id}"));
};
let bytes = fs::read(&snapshot).map_err(|error| {
format!(
"failed to read rollback snapshot {}: {error}",
snapshot.display()
)
})?;
let source = if snapshot
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.contains(".trusted."))
{
PackSource::Trusted
} else {
PackSource::User
};
let target = install_bytes_as_pack(config_dir, pack_id, source, &bytes)?;
Ok(target)
}
pub fn sign_pack_bytes(bytes: &[u8], key_id: &str, secret: &str) -> String {
let mut hash = Sha256::new();
hash.update(b"index.pack.signature.v1");
hash.update([0]);
hash.update(key_id.as_bytes());
hash.update([0]);
hash.update(secret.as_bytes());
hash.update([0]);
hash.update(bytes);
hex_encode(hash.finalize().as_slice())
}
pub fn verify_pack_signature(bytes: &[u8], key_id: &str, secret: &str, signature: &str) -> bool {
sign_pack_bytes(bytes, key_id, secret).eq_ignore_ascii_case(signature.trim())
}
fn create_rollback_snapshot(
config_dir: &str,
pack_id: &str,
source: PackSource,
bytes: &[u8],
) -> Result<(), String> {
let directory = Path::new(config_dir)
.join(COMPATIBILITY_PACK_ROLLBACK_DIR)
.join(pack_id);
fs::create_dir_all(&directory).map_err(|error| {
format!(
"failed to create rollback directory {}: {error}",
directory.display()
)
})?;
let stamp = unix_timestamp_nanos();
let filename = format!("{}.{}.pack.json", stamp, source.as_str());
let path = directory.join(filename);
fs::write(&path, bytes).map_err(|error| {
format!(
"failed to write rollback snapshot {}: {error}",
path.display()
)
})?;
Ok(())
}
fn install_bytes_as_pack(
config_dir: &str,
pack_id: &str,
source: PackSource,
bytes: &[u8],
) -> Result<PathBuf, String> {
let relative_dir = match source {
PackSource::User => COMPATIBILITY_PACK_USER_DIR,
PackSource::Trusted => COMPATIBILITY_PACK_TRUSTED_DIR,
PackSource::BuiltIn => {
return Err("cannot install into built-in source".to_owned());
}
};
let directory = Path::new(config_dir).join(relative_dir);
fs::create_dir_all(&directory).map_err(|error| {
format!(
"failed to create compatibility pack directory {}: {error}",
directory.display()
)
})?;
let target = directory.join(format!("{pack_id}.pack.json"));
fs::write(&target, bytes).map_err(|error| {
format!(
"failed to write compatibility pack {}: {error}",
target.display()
)
})?;
Ok(target)
}
fn unix_timestamp_nanos() -> u128 {
match SystemTime::now().duration_since(UNIX_EPOCH) {
Ok(duration) => duration.as_nanos(),
Err(_) => 0,
}
}
fn hex_encode(bytes: &[u8]) -> String {
let mut output = String::with_capacity(bytes.len() * 2);
for byte in bytes {
output.push(char::from_digit(((byte >> 4) & 0x0f).into(), 16).unwrap_or('0'));
output.push(char::from_digit((byte & 0x0f).into(), 16).unwrap_or('0'));
}
output
}
#[cfg(test)]
mod tests {
use std::fs;
use super::{
COMPATIBILITY_PACK_POLICY_FILE, COMPATIBILITY_PACK_ROLLBACK_DIR,
COMPATIBILITY_PACK_TRUSTED_DIR, COMPATIBILITY_PACK_USER_DIR, CompatibilityPackPolicy,
PackSource, create_rollback_snapshot, inspect_runtime_for_url, install_bytes_as_pack,
install_pack_file, lint_pack_file, list_runtime_pack_files, load_pack_policy,
rollback_pack_file, select_runtime_manifest_for_url, sign_pack_bytes,
verify_pack_signature,
};
fn unique_temp_dir(prefix: &str) -> std::path::PathBuf {
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
std::env::temp_dir().join(format!("index-{prefix}-{stamp}"))
}
fn sample_pack(host: &str, selector: &str) -> String {
format!(
r#"{{
"version": "index.pack/v1",
"id": "sample-{host}",
"rules": [
{{
"host": "{host}",
"path_prefix": "/docs",
"manifest": {{
"version": "index.idx/v1",
"scope": "/docs",
"content": {{
"main_selector": "{selector}"
}}
}}
}}
]
}}"#
)
}
#[test]
fn policy_defaults_when_file_missing() -> Result<(), Box<dyn std::error::Error>> {
let dir = unique_temp_dir("compat-pack-policy-default");
let policy = load_pack_policy(dir.to_string_lossy().as_ref())?;
assert_eq!(policy, CompatibilityPackPolicy::default());
Ok(())
}
#[test]
fn policy_parses_explicit_booleans() -> Result<(), Box<dyn std::error::Error>> {
let dir = unique_temp_dir("compat-pack-policy-config");
fs::create_dir_all(&dir)?;
fs::write(
dir.join(COMPATIBILITY_PACK_POLICY_FILE),
"enabled=true\nallow_user=false\nallow_trusted=true\n",
)?;
let policy = load_pack_policy(dir.to_string_lossy().as_ref())?;
assert_eq!(
policy,
CompatibilityPackPolicy {
enabled: true,
allow_user: false,
allow_trusted: true
}
);
Ok(())
}
#[test]
fn policy_ignores_comments_and_blank_lines() -> Result<(), Box<dyn std::error::Error>> {
let dir = unique_temp_dir("compat-pack-policy-comments");
fs::create_dir_all(&dir)?;
fs::write(
dir.join(COMPATIBILITY_PACK_POLICY_FILE),
"# comment\n\n enabled = true \n allow_user = true \n allow_trusted = false \n",
)?;
let policy = load_pack_policy(dir.to_string_lossy().as_ref())?;
assert!(policy.enabled);
assert!(policy.allow_user);
assert!(!policy.allow_trusted);
Ok(())
}
#[test]
fn policy_rejects_invalid_lines_and_values() -> Result<(), Box<dyn std::error::Error>> {
let missing_equals = unique_temp_dir("compat-pack-policy-missing-equals");
fs::create_dir_all(&missing_equals)?;
fs::write(
missing_equals.join(COMPATIBILITY_PACK_POLICY_FILE),
"enabled\n",
)?;
let error = load_pack_policy(missing_equals.to_string_lossy().as_ref())
.err()
.unwrap_or_default();
assert!(error.contains("expected key=value"));
let invalid_bool = unique_temp_dir("compat-pack-policy-invalid-bool");
fs::create_dir_all(&invalid_bool)?;
fs::write(
invalid_bool.join(COMPATIBILITY_PACK_POLICY_FILE),
"enabled=maybe\n",
)?;
let error = load_pack_policy(invalid_bool.to_string_lossy().as_ref())
.err()
.unwrap_or_default();
assert!(error.contains("invalid compatibility pack policy boolean"));
let invalid_key = unique_temp_dir("compat-pack-policy-invalid-key");
fs::create_dir_all(&invalid_key)?;
fs::write(
invalid_key.join(COMPATIBILITY_PACK_POLICY_FILE),
"allow_remote=true\n",
)?;
let error = load_pack_policy(invalid_key.to_string_lossy().as_ref())
.err()
.unwrap_or_default();
assert!(error.contains("unsupported compatibility pack policy key"));
Ok(())
}
#[test]
fn lint_reports_pass_for_valid_pack() -> Result<(), Box<dyn std::error::Error>> {
let dir = unique_temp_dir("compat-pack-lint");
fs::create_dir_all(&dir)?;
let pack_path = dir.join("sample.pack.json");
fs::write(&pack_path, sample_pack("example.org", "main article"))?;
let report = lint_pack_file(
pack_path.to_string_lossy().as_ref(),
"https://example.org/docs/page",
)?;
assert!(report.contains("index-compat-pack-lint-v1"));
assert!(report.contains("result: pass"));
assert!(report.contains("match: host=example.org"));
Ok(())
}
#[test]
fn lint_rejects_invalid_host_rules() -> Result<(), Box<dyn std::error::Error>> {
let dir = unique_temp_dir("compat-pack-lint-invalid-host");
fs::create_dir_all(&dir)?;
let pack_path = dir.join("invalid.pack.json");
fs::write(&pack_path, sample_pack("localhost", "main article"))?;
let error = lint_pack_file(
pack_path.to_string_lossy().as_ref(),
"https://example.org/docs/page",
)
.err()
.unwrap_or_default();
assert!(error.contains("invalid host"));
Ok(())
}
#[test]
fn selection_prefers_user_over_trusted() -> Result<(), Box<dyn std::error::Error>> {
let dir = unique_temp_dir("compat-pack-precedence");
fs::create_dir_all(dir.join(COMPATIBILITY_PACK_USER_DIR))?;
fs::create_dir_all(dir.join(COMPATIBILITY_PACK_TRUSTED_DIR))?;
fs::write(
dir.join(COMPATIBILITY_PACK_TRUSTED_DIR)
.join("trusted.pack.json"),
sample_pack("example.org", "main trusted"),
)?;
fs::write(
dir.join(COMPATIBILITY_PACK_USER_DIR).join("user.pack.json"),
sample_pack("example.org", "main user"),
)?;
let selected = select_runtime_manifest_for_url(
dir.to_string_lossy().as_ref(),
"https://example.org/docs/page",
)?;
assert!(selected.is_some());
let selected = selected.unwrap_or_else(|| unreachable!());
assert_eq!(selected.source, PackSource::User);
assert_eq!(selected.pack_id, "sample-example.org");
assert_eq!(
selected.manifest.content.main_selector.as_deref(),
Some("main user")
);
Ok(())
}
#[test]
fn invalid_pack_fails_closed() -> Result<(), Box<dyn std::error::Error>> {
let dir = unique_temp_dir("compat-pack-invalid");
fs::create_dir_all(dir.join(COMPATIBILITY_PACK_USER_DIR))?;
fs::write(
dir.join(COMPATIBILITY_PACK_USER_DIR)
.join("broken.pack.json"),
r#"{"version":"index.pack/v1","id":"broken","rules":[{"host":"example.org","manifest":{"version":"index.idx/v1","scope":"docs"}}]}"#,
)?;
let selected = select_runtime_manifest_for_url(
dir.to_string_lossy().as_ref(),
"https://example.org/docs/page",
);
assert!(selected.is_err());
Ok(())
}
#[test]
fn install_pack_writes_to_config_tree() -> Result<(), Box<dyn std::error::Error>> {
let dir = unique_temp_dir("compat-pack-install");
fs::create_dir_all(&dir)?;
let pack_path = dir.join("install.pack.json");
fs::write(&pack_path, sample_pack("example.org", "main article"))?;
let target = install_pack_file(
dir.to_string_lossy().as_ref(),
pack_path.to_string_lossy().as_ref(),
PackSource::User,
)?;
assert!(target.exists());
Ok(())
}
#[test]
fn install_pack_rejects_builtin_source() -> Result<(), Box<dyn std::error::Error>> {
let dir = unique_temp_dir("compat-pack-install-builtin");
fs::create_dir_all(&dir)?;
let pack_path = dir.join("install.pack.json");
fs::write(&pack_path, sample_pack("example.org", "main article"))?;
let error = install_pack_file(
dir.to_string_lossy().as_ref(),
pack_path.to_string_lossy().as_ref(),
PackSource::BuiltIn,
)
.err()
.unwrap_or_default();
assert!(error.contains("cannot install into built-in source"));
Ok(())
}
#[test]
fn install_pack_writes_to_trusted_tree() -> Result<(), Box<dyn std::error::Error>> {
let dir = unique_temp_dir("compat-pack-install-trusted");
fs::create_dir_all(&dir)?;
let pack_path = dir.join("install.pack.json");
fs::write(&pack_path, sample_pack("example.org", "main article"))?;
let target = install_pack_file(
dir.to_string_lossy().as_ref(),
pack_path.to_string_lossy().as_ref(),
PackSource::Trusted,
)?;
assert!(target.exists());
assert!(
target
.to_string_lossy()
.contains(COMPATIBILITY_PACK_TRUSTED_DIR)
);
Ok(())
}
#[test]
fn rollback_restores_previous_pack_snapshot() -> Result<(), Box<dyn std::error::Error>> {
let dir = unique_temp_dir("compat-pack-rollback");
fs::create_dir_all(&dir)?;
let first = dir.join("first.pack.json");
let second = dir.join("second.pack.json");
fs::write(&first, sample_pack("example.org", "main first"))?;
fs::write(&second, sample_pack("example.org", "main second"))?;
let _ = install_pack_file(
dir.to_string_lossy().as_ref(),
first.to_string_lossy().as_ref(),
PackSource::User,
)?;
let installed = install_pack_file(
dir.to_string_lossy().as_ref(),
second.to_string_lossy().as_ref(),
PackSource::User,
)?;
let before = fs::read_to_string(&installed)?;
assert!(before.contains("main second"));
let restored = rollback_pack_file(dir.to_string_lossy().as_ref(), "sample-example.org")?;
let after = fs::read_to_string(&restored)?;
assert!(after.contains("main first"));
Ok(())
}
#[test]
fn rollback_reports_missing_snapshot() -> Result<(), Box<dyn std::error::Error>> {
let dir = unique_temp_dir("compat-pack-rollback-empty");
let rollback = dir
.join(COMPATIBILITY_PACK_ROLLBACK_DIR)
.join("sample-pack");
fs::create_dir_all(&rollback)?;
let error = rollback_pack_file(dir.to_string_lossy().as_ref(), "sample-pack")
.err()
.unwrap_or_default();
assert!(error.contains("no rollback snapshots available for sample-pack"));
Ok(())
}
#[test]
fn rollback_prefers_trusted_snapshot_source() -> Result<(), Box<dyn std::error::Error>> {
let dir = unique_temp_dir("compat-pack-rollback-trusted");
fs::create_dir_all(&dir)?;
let bytes = sample_pack("example.org", "main trusted");
create_rollback_snapshot(
dir.to_string_lossy().as_ref(),
"sample-example.org",
PackSource::Trusted,
bytes.as_bytes(),
)?;
let restored = rollback_pack_file(dir.to_string_lossy().as_ref(), "sample-example.org")?;
assert!(
restored
.to_string_lossy()
.contains(COMPATIBILITY_PACK_TRUSTED_DIR)
);
Ok(())
}
#[test]
fn inspect_runtime_reports_disabled_policy() -> Result<(), Box<dyn std::error::Error>> {
let dir = unique_temp_dir("compat-pack-inspect-disabled");
fs::create_dir_all(&dir)?;
fs::write(
dir.join(COMPATIBILITY_PACK_POLICY_FILE),
"enabled=false\nallow_user=true\nallow_trusted=true\n",
)?;
let report = inspect_runtime_for_url(dir.to_string_lossy().as_ref(), "example.org/docs")?;
assert!(report.contains("policy: enabled=false"));
assert!(report.contains("selected: none"));
Ok(())
}
#[test]
fn inspect_runtime_reports_selected_pack() -> Result<(), Box<dyn std::error::Error>> {
let dir = unique_temp_dir("compat-pack-inspect-selected");
fs::create_dir_all(dir.join(COMPATIBILITY_PACK_TRUSTED_DIR))?;
fs::write(
dir.join(COMPATIBILITY_PACK_TRUSTED_DIR)
.join("trusted.pack.json"),
sample_pack("example.org", "main trusted"),
)?;
let report =
inspect_runtime_for_url(dir.to_string_lossy().as_ref(), "https://example.org/docs/a")?;
assert!(report.contains("loaded_packs: 2"));
assert!(report.contains("selected: trusted:sample-example.org"));
assert!(report.contains("host=example.org path_prefix=/docs"));
Ok(())
}
#[test]
fn inspect_runtime_reports_none_when_no_rule_matches() -> Result<(), Box<dyn std::error::Error>>
{
let dir = unique_temp_dir("compat-pack-inspect-none");
fs::create_dir_all(dir.join(COMPATIBILITY_PACK_USER_DIR))?;
fs::write(
dir.join(COMPATIBILITY_PACK_USER_DIR).join("user.pack.json"),
sample_pack("alpha.example", "main alpha"),
)?;
let report = inspect_runtime_for_url(
dir.to_string_lossy().as_ref(),
"https://example.org/docs/article",
)?;
assert!(report.contains("loaded_packs: 2"));
assert!(report.contains("selected: none"));
Ok(())
}
#[test]
fn selection_returns_none_when_policy_disabled() -> Result<(), Box<dyn std::error::Error>> {
let dir = unique_temp_dir("compat-pack-selection-disabled");
fs::create_dir_all(&dir)?;
fs::write(
dir.join(COMPATIBILITY_PACK_POLICY_FILE),
"enabled=false\nallow_user=true\nallow_trusted=true\n",
)?;
let selected = select_runtime_manifest_for_url(
dir.to_string_lossy().as_ref(),
"https://example.org/docs/page",
)?;
assert!(selected.is_none());
Ok(())
}
#[test]
fn lint_rejects_invalid_pack_shapes() -> Result<(), Box<dyn std::error::Error>> {
let dir = unique_temp_dir("compat-pack-invalid-shapes");
fs::create_dir_all(&dir)?;
let invalid_json = dir.join("invalid-json.pack.json");
fs::write(&invalid_json, "{not-json}")?;
let invalid_json_error = lint_pack_file(
invalid_json.to_string_lossy().as_ref(),
"https://example.org/docs",
)
.err()
.unwrap_or_default();
assert!(invalid_json_error.contains("invalid JSON"));
let invalid_version = dir.join("invalid-version.pack.json");
fs::write(
&invalid_version,
r#"{"version":"index.pack/v9","id":"x","rules":[]}"#,
)?;
let invalid_version_error = lint_pack_file(
invalid_version.to_string_lossy().as_ref(),
"https://example.org/docs",
)
.err()
.unwrap_or_default();
assert!(invalid_version_error.contains("unsupported version"));
let invalid_id = dir.join("invalid-id.pack.json");
fs::write(
&invalid_id,
r#"{"version":"index.pack/v1","id":"bad id","rules":[]}"#,
)?;
let invalid_id_error = lint_pack_file(
invalid_id.to_string_lossy().as_ref(),
"https://example.org/docs",
)
.err()
.unwrap_or_default();
assert!(invalid_id_error.contains("invalid id"));
let invalid_prefix = dir.join("invalid-prefix.pack.json");
fs::write(
&invalid_prefix,
r#"{
"version":"index.pack/v1",
"id":"invalid-prefix",
"rules":[
{
"host":"example.org",
"path_prefix":"docs",
"manifest":{"version":"index.idx/v1","scope":"/docs"}
}
]
}"#,
)?;
let invalid_prefix_error = lint_pack_file(
invalid_prefix.to_string_lossy().as_ref(),
"https://example.org/docs",
)
.err()
.unwrap_or_default();
assert!(invalid_prefix_error.contains("invalid path_prefix"));
let invalid_manifest = dir.join("invalid-manifest.pack.json");
fs::write(
&invalid_manifest,
r#"{
"version":"index.pack/v1",
"id":"invalid-manifest",
"rules":[
{
"host":"example.org",
"path_prefix":"/docs",
"manifest":{"version":"index.idx/v1","scope":"docs"}
}
]
}"#,
)?;
let invalid_manifest_error = lint_pack_file(
invalid_manifest.to_string_lossy().as_ref(),
"https://example.org/docs",
)
.err()
.unwrap_or_default();
assert!(invalid_manifest_error.contains("manifest is invalid"));
Ok(())
}
#[test]
fn lint_rejects_validator_limit_shapes() -> Result<(), Box<dyn std::error::Error>> {
let dir = unique_temp_dir("compat-pack-validator-limits");
fs::create_dir_all(&dir)?;
let empty_id = dir.join("empty-id.pack.json");
fs::write(
&empty_id,
r#"{"version":"index.pack/v1","id":" ","rules":[]}"#,
)?;
let empty_id_error = lint_pack_file(
empty_id.to_string_lossy().as_ref(),
"https://example.org/docs",
)
.err()
.unwrap_or_default();
assert!(empty_id_error.contains("id must not be empty"));
let long_id = dir.join("long-id.pack.json");
let long_id_value = "a".repeat(129);
fs::write(
&long_id,
format!(r#"{{"version":"index.pack/v1","id":"{long_id_value}","rules":[]}}"#),
)?;
let long_id_error = lint_pack_file(
long_id.to_string_lossy().as_ref(),
"https://example.org/docs",
)
.err()
.unwrap_or_default();
assert!(long_id_error.contains("id exceeds 128 characters"));
let empty_host = dir.join("empty-host.pack.json");
fs::write(
&empty_host,
r#"{
"version":"index.pack/v1",
"id":"empty-host",
"rules":[
{"host":"", "path_prefix":"/docs", "manifest":{"version":"index.idx/v1","scope":"/docs"}}
]
}"#,
)?;
let empty_host_error = lint_pack_file(
empty_host.to_string_lossy().as_ref(),
"https://example.org/docs",
)
.err()
.unwrap_or_default();
assert!(empty_host_error.contains("host must not be empty"));
let long_host = dir.join("long-host.pack.json");
let long_host_value = format!("{}.example.org", "a".repeat(245));
fs::write(
&long_host,
format!(
r#"{{"version":"index.pack/v1","id":"long-host","rules":[{{"host":"{long_host_value}","path_prefix":"/docs","manifest":{{"version":"index.idx/v1","scope":"/docs"}}}}]}}"#
),
)?;
let long_host_error = lint_pack_file(
long_host.to_string_lossy().as_ref(),
"https://example.org/docs",
)
.err()
.unwrap_or_default();
assert!(long_host_error.contains("host exceeds 253 characters"));
let invalid_host_chars = dir.join("invalid-host-chars.pack.json");
fs::write(
&invalid_host_chars,
r#"{
"version":"index.pack/v1",
"id":"invalid-host",
"rules":[
{"host":"exa$mple.org", "path_prefix":"/docs", "manifest":{"version":"index.idx/v1","scope":"/docs"}}
]
}"#,
)?;
let invalid_host_chars_error = lint_pack_file(
invalid_host_chars.to_string_lossy().as_ref(),
"https://example.org/docs",
)
.err()
.unwrap_or_default();
assert!(invalid_host_chars_error.contains("host may only use"));
let empty_prefix = dir.join("empty-prefix.pack.json");
fs::write(
&empty_prefix,
r#"{
"version":"index.pack/v1",
"id":"empty-prefix",
"rules":[
{"host":"example.org", "path_prefix":"", "manifest":{"version":"index.idx/v1","scope":"/docs"}}
]
}"#,
)?;
let empty_prefix_error = lint_pack_file(
empty_prefix.to_string_lossy().as_ref(),
"https://example.org/docs",
)
.err()
.unwrap_or_default();
assert!(empty_prefix_error.contains("path_prefix must not be empty"));
let spaced_prefix = dir.join("spaced-prefix.pack.json");
fs::write(
&spaced_prefix,
r#"{
"version":"index.pack/v1",
"id":"spaced-prefix",
"rules":[
{"host":"example.org", "path_prefix":"/docs section", "manifest":{"version":"index.idx/v1","scope":"/docs"}}
]
}"#,
)?;
let spaced_prefix_error = lint_pack_file(
spaced_prefix.to_string_lossy().as_ref(),
"https://example.org/docs",
)
.err()
.unwrap_or_default();
assert!(spaced_prefix_error.contains("path_prefix may not contain '*' or spaces"));
let long_prefix = dir.join("long-prefix.pack.json");
let long_prefix_value = format!("/{}", "a".repeat(257));
fs::write(
&long_prefix,
format!(
r#"{{"version":"index.pack/v1","id":"long-prefix","rules":[{{"host":"example.org","path_prefix":"{long_prefix_value}","manifest":{{"version":"index.idx/v1","scope":"/docs"}}}}]}}"#
),
)?;
let long_prefix_error = lint_pack_file(
long_prefix.to_string_lossy().as_ref(),
"https://example.org/docs",
)
.err()
.unwrap_or_default();
assert!(long_prefix_error.contains("path_prefix exceeds 256 characters"));
Ok(())
}
#[test]
fn list_runtime_pack_files_reports_user_and_trusted() -> Result<(), Box<dyn std::error::Error>>
{
let dir = unique_temp_dir("compat-pack-list");
fs::create_dir_all(dir.join(COMPATIBILITY_PACK_USER_DIR))?;
fs::create_dir_all(dir.join(COMPATIBILITY_PACK_TRUSTED_DIR))?;
fs::write(
dir.join(COMPATIBILITY_PACK_USER_DIR)
.join("alpha.pack.json"),
sample_pack("alpha.example", "main alpha"),
)?;
fs::write(
dir.join(COMPATIBILITY_PACK_TRUSTED_DIR)
.join("beta.pack.json"),
sample_pack("beta.example", "main beta"),
)?;
let entries = list_runtime_pack_files(dir.to_string_lossy().as_ref());
assert_eq!(entries.len(), 2);
assert!(entries.iter().any(|entry| entry.starts_with("user\t")));
assert!(entries.iter().any(|entry| entry.starts_with("trusted\t")));
Ok(())
}
#[test]
fn sign_and_verify_are_deterministic() {
let bytes = br#"{"version":"index.pack/v1","id":"x","rules":[]}"#;
let signature = sign_pack_bytes(bytes, "k1", "secret");
assert!(verify_pack_signature(bytes, "k1", "secret", &signature));
assert!(verify_pack_signature(
bytes,
"k1",
"secret",
&signature.to_ascii_uppercase()
));
assert!(!verify_pack_signature(bytes, "k1", "other", &signature));
}
#[test]
fn pack_source_labels_and_url_helpers_are_stable() {
assert_eq!(PackSource::User.as_str(), "user");
assert_eq!(PackSource::Trusted.as_str(), "trusted");
assert_eq!(PackSource::BuiltIn.as_str(), "built-in");
assert_eq!(
super::normalize_url("example.org/docs"),
Ok("https://example.org/docs".to_owned())
);
assert_eq!(
super::normalize_url("https://example.org/docs"),
Ok("https://example.org/docs".to_owned())
);
assert_eq!(
super::url_host_path("https://example.org"),
Some(("example.org".to_owned(), "/".to_owned()))
);
}
#[test]
fn load_pack_policy_reports_non_directory_config_error()
-> Result<(), Box<dyn std::error::Error>> {
let dir = unique_temp_dir("compat-pack-policy-read-error");
fs::create_dir_all(&dir)?;
let file_like_config_dir = dir.join("config-file");
fs::write(&file_like_config_dir, "not-a-directory")?;
let error = load_pack_policy(file_like_config_dir.to_string_lossy().as_ref())
.err()
.unwrap_or_default();
assert!(error.contains("failed to read compatibility pack policy"));
Ok(())
}
#[test]
fn lint_rejects_oversized_non_utf8_and_excessive_rule_counts()
-> Result<(), Box<dyn std::error::Error>> {
let dir = unique_temp_dir("compat-pack-parse-guards");
fs::create_dir_all(&dir)?;
let oversized = dir.join("oversized.pack.json");
fs::write(
&oversized,
vec![b'x'; super::COMPATIBILITY_PACK_MAX_BYTES + 1],
)?;
let oversized_error = lint_pack_file(
oversized.to_string_lossy().as_ref(),
"https://example.org/docs",
)
.err()
.unwrap_or_default();
assert!(oversized_error.contains("exceeds limit"));
let non_utf8 = dir.join("non-utf8.pack.json");
fs::write(&non_utf8, [0xff_u8, 0xfe_u8, 0xfd_u8])?;
let non_utf8_error = lint_pack_file(
non_utf8.to_string_lossy().as_ref(),
"https://example.org/docs",
)
.err()
.unwrap_or_default();
assert!(non_utf8_error.contains("is not UTF-8"));
let rules = (0..(super::COMPATIBILITY_PACK_MAX_RULES + 1))
.map(|index| {
format!(
r#"{{"host":"r{index}.example.org","path_prefix":"/docs","manifest":{{"version":"index.idx/v1","scope":"/docs"}}}}"#
)
})
.collect::<Vec<_>>()
.join(",");
let too_many = dir.join("too-many-rules.pack.json");
fs::write(
&too_many,
format!(r#"{{"version":"index.pack/v1","id":"too-many","rules":[{rules}]}}"#),
)?;
let too_many_error = lint_pack_file(
too_many.to_string_lossy().as_ref(),
"https://example.org/docs",
)
.err()
.unwrap_or_default();
assert!(too_many_error.contains("too many rules"));
Ok(())
}
#[test]
fn install_pack_reports_directory_creation_errors() -> Result<(), Box<dyn std::error::Error>> {
let dir = unique_temp_dir("compat-pack-install-dir-error");
fs::create_dir_all(&dir)?;
let blocked_config_dir = dir.join("blocked-config-root");
fs::write(&blocked_config_dir, "not-a-directory")?;
let pack_path = dir.join("valid.pack.json");
fs::write(&pack_path, sample_pack("example.org", "main article"))?;
let error = install_pack_file(
blocked_config_dir.to_string_lossy().as_ref(),
pack_path.to_string_lossy().as_ref(),
PackSource::User,
)
.err()
.unwrap_or_default();
assert!(error.contains("failed to create compatibility pack directory"));
Ok(())
}
#[test]
fn rollback_reports_missing_directory_error() -> Result<(), Box<dyn std::error::Error>> {
let dir = unique_temp_dir("compat-pack-rollback-missing-dir");
fs::create_dir_all(&dir)?;
let error = rollback_pack_file(dir.to_string_lossy().as_ref(), "missing-pack")
.err()
.unwrap_or_default();
assert!(error.contains("failed to read rollback directory"));
Ok(())
}
#[test]
fn inspect_runtime_reports_directory_scan_errors() -> Result<(), Box<dyn std::error::Error>> {
let dir = unique_temp_dir("compat-pack-inspect-scan-error");
fs::create_dir_all(dir.join("compat-packs"))?;
fs::write(dir.join(COMPATIBILITY_PACK_USER_DIR), "not-a-directory")?;
let user_error =
inspect_runtime_for_url(dir.to_string_lossy().as_ref(), "https://example.org/docs")
.err()
.unwrap_or_default();
assert!(user_error.contains("failed to read compatibility pack directory"));
fs::remove_file(dir.join(COMPATIBILITY_PACK_USER_DIR))?;
fs::write(
dir.join(COMPATIBILITY_PACK_POLICY_FILE),
"enabled=true\nallow_user=false\nallow_trusted=true\n",
)?;
fs::write(dir.join(COMPATIBILITY_PACK_TRUSTED_DIR), "not-a-directory")?;
let trusted_error =
inspect_runtime_for_url(dir.to_string_lossy().as_ref(), "https://example.org/docs")
.err()
.unwrap_or_default();
assert!(trusted_error.contains("failed to read compatibility pack directory"));
Ok(())
}
#[test]
fn install_bytes_rejects_builtin_target() {
let error = install_bytes_as_pack(
"/tmp/index-nonexistent",
"sample-pack",
PackSource::BuiltIn,
br#"{"version":"index.pack/v1","id":"sample-pack","rules":[]}"#,
)
.err()
.unwrap_or_default();
assert!(error.contains("cannot install into built-in source"));
}
#[test]
fn install_bytes_writes_to_trusted_tree() -> Result<(), Box<dyn std::error::Error>> {
let dir = unique_temp_dir("compat-pack-install-bytes-trusted");
fs::create_dir_all(&dir)?;
let target = install_bytes_as_pack(
dir.to_string_lossy().as_ref(),
"sample-trusted-pack",
PackSource::Trusted,
br#"{"version":"index.pack/v1","id":"sample-trusted-pack","rules":[]}"#,
)?;
assert!(target.exists());
assert!(
target
.to_string_lossy()
.contains(COMPATIBILITY_PACK_TRUSTED_DIR)
);
Ok(())
}
}