use std::fmt;
use std::fs::File;
use std::io::Read;
use std::path::{Component, Path, PathBuf};
use std::sync::LazyLock;
use crate::json::cloud_api::POLICY_MAX_BYTES;
use crate::json::JsonPolicy;
pub const POLICY_DIR: &str = "hotpath";
const SHARED_POLICY_FILE: &str = "policy.toml";
const BENCHMARK_POLICY_SUFFIX: &str = "-policy.toml";
pub static POLICY_PATH: LazyLock<Option<PathBuf>> = LazyLock::new(|| {
std::env::var_os("HOTPATH_META_POLICY_PATH")
.filter(|path| !path.to_string_lossy().trim().is_empty())
.map(PathBuf::from)
});
#[derive(Debug)]
pub enum PolicyLookup {
NotFound,
Found(JsonPolicy),
Unusable(UnusablePolicy),
}
#[derive(Debug)]
pub struct UnusablePolicy {
pub file: String,
pub reason: UnusableReason,
}
#[derive(Debug)]
pub enum UnusableReason {
Unreadable(std::io::Error),
NotUtf8(std::string::FromUtf8Error),
Blank,
TooLarge,
OutsideRepository {
root: PathBuf,
},
NoRepository,
}
impl fmt::Display for UnusablePolicy {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let file = &self.file;
match &self.reason {
UnusableReason::Unreadable(error) => {
write!(f, "could not read the policy file {file}: {error}.")
}
UnusableReason::NotUtf8(error) => {
write!(f, "the policy file {file} is not valid UTF-8: {error}.")
}
UnusableReason::Blank => write!(f, "the policy file {file} is blank."),
UnusableReason::TooLarge => write!(
f,
"the policy file {file} is larger than {POLICY_MAX_BYTES} bytes, the most the server stores."
),
UnusableReason::OutsideRepository { root } => write!(
f,
"the policy file {file} is outside the repository `{}`.",
root.display()
),
UnusableReason::NoRepository => write!(
f,
"the policy file {file} cannot be placed in a repository: no git repository was found."
),
}
}
}
pub fn policy_files_hint(benchmark: Option<&str>) -> String {
let shared = format!("`{}`", policy_file_path(None));
match benchmark {
Some(_) => format!("`{}` or {shared}", policy_file_path(benchmark)),
None => shared,
}
}
pub fn policy_file_path(benchmark: Option<&str>) -> String {
format!("{POLICY_DIR}/{}", policy_file_name(benchmark))
}
fn policy_file_name(benchmark: Option<&str>) -> String {
match benchmark {
Some(name) => format!("{name}{BENCHMARK_POLICY_SUFFIX}"),
None => SHARED_POLICY_FILE.to_string(),
}
}
pub fn find_git_root(start: &Path) -> Option<PathBuf> {
start
.ancestors()
.find(|dir| dir.join(".git").exists())
.map(Path::to_path_buf)
}
pub fn lookup(git_root: Option<&Path>, benchmark: Option<&str>) -> PolicyLookup {
if let Some(path) = POLICY_PATH.as_deref() {
return match read_override(git_root, path) {
Ok(policy) => PolicyLookup::Found(policy),
Err(unusable) => PolicyLookup::Unusable(unusable),
};
}
let Some(git_root) = git_root else {
return PolicyLookup::NotFound;
};
let benchmark_file = benchmark.map(|name| policy_file_name(Some(name)));
for file_name in benchmark_file.into_iter().chain([policy_file_name(None)]) {
match read_in_policy_dir(git_root, &file_name) {
Ok(None) => {}
Ok(Some(policy)) => return PolicyLookup::Found(policy),
Err(unusable) => return PolicyLookup::Unusable(unusable),
}
}
PolicyLookup::NotFound
}
pub fn read_source(reader: &mut dyn Read, file: &str) -> Result<String, UnusablePolicy> {
let unusable = |reason| UnusablePolicy {
file: file.to_string(),
reason,
};
let mut bytes = Vec::new();
reader
.take(POLICY_MAX_BYTES as u64 + 1)
.read_to_end(&mut bytes)
.map_err(|error| unusable(UnusableReason::Unreadable(error)))?;
if bytes.len() > POLICY_MAX_BYTES {
return Err(unusable(UnusableReason::TooLarge));
}
let source =
String::from_utf8(bytes).map_err(|error| unusable(UnusableReason::NotUtf8(error)))?;
if source.trim().is_empty() {
return Err(unusable(UnusableReason::Blank));
}
Ok(source)
}
pub fn read_file(path: &Path) -> Result<String, UnusablePolicy> {
let file = format!("`{}`", path.display());
match File::open(path) {
Ok(mut reader) => read_source(&mut reader, &file),
Err(error) => Err(UnusablePolicy {
file,
reason: UnusableReason::Unreadable(error),
}),
}
}
fn read_in_policy_dir(
git_root: &Path,
file_name: &str,
) -> Result<Option<JsonPolicy>, UnusablePolicy> {
let dir = git_root.join(POLICY_DIR);
if !dir.is_dir() {
return Ok(None);
}
let path = dir.join(file_name);
let file = format!("`{POLICY_DIR}/{file_name}`");
match path.symlink_metadata() {
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(UnusablePolicy {
file,
reason: UnusableReason::Unreadable(error),
})
}
}
read_in_repository(git_root, &path, file).map(Some)
}
fn read_override(git_root: Option<&Path>, path: &Path) -> Result<JsonPolicy, UnusablePolicy> {
let file = format!("`{}` (HOTPATH_META_POLICY_PATH)", path.display());
let Some(git_root) = git_root else {
return Err(UnusablePolicy {
file,
reason: UnusableReason::NoRepository,
});
};
let path = if path.is_absolute() {
path.to_path_buf()
} else {
match std::env::current_dir() {
Ok(cwd) => cwd.join(path),
Err(error) => {
return Err(UnusablePolicy {
file,
reason: UnusableReason::Unreadable(error),
})
}
}
};
read_in_repository(git_root, &path, file)
}
fn read_in_repository(
git_root: &Path,
path: &Path,
file: String,
) -> Result<JsonPolicy, UnusablePolicy> {
let unreadable = |error| UnusablePolicy {
file: file.clone(),
reason: UnusableReason::Unreadable(error),
};
let resolved = path.canonicalize().map_err(unreadable)?;
let root = git_root.canonicalize().map_err(unreadable)?;
let Some(relative) = repository_path(&root, &resolved) else {
return Err(UnusablePolicy {
file,
reason: UnusableReason::OutsideRepository {
root: git_root.to_path_buf(),
},
});
};
let mut reader = File::open(&resolved).map_err(unreadable)?;
let source = read_source(&mut reader, &file)?;
Ok(JsonPolicy {
source,
path: relative,
})
}
fn repository_path(root: &Path, resolved: &Path) -> Option<String> {
let relative = resolved.strip_prefix(root).ok()?;
let segments = relative
.components()
.map(|component| match component {
Component::Normal(segment) => segment.to_str(),
_ => None,
})
.collect::<Option<Vec<_>>>()?;
(!segments.is_empty()).then(|| segments.join("/"))
}