use std::collections::BTreeSet;
use std::path::{Component, Path, PathBuf};
use futures::StreamExt;
use serde::Deserialize;
use thiserror::Error;
use super::Config;
pub const PATH_VAR: &str = "DREP_SITE_CONFIG";
const ROOT_RESOLUTION_CONCURRENCY: usize = 4;
#[cfg(target_os = "macos")]
const MACHINE_PATH: &str = "/Library/Application Support/drep/site.toml";
#[cfg(not(target_os = "macos"))]
const MACHINE_PATH: &str = "/etc/drep/site.toml";
pub fn default_path() -> PathBuf {
path_from(std::env::var_os(PATH_VAR), machine_path())
}
pub fn machine_path() -> &'static Path {
Path::new(MACHINE_PATH)
}
pub fn path_from(overridden: Option<std::ffi::OsString>, machine: &Path) -> PathBuf {
match overridden {
Some(path)
if !path.is_empty()
&& matches!(
std::fs::symlink_metadata(machine),
Err(ref err) if err.kind() == std::io::ErrorKind::NotFound
) =>
{
PathBuf::from(path)
}
_ => machine.to_path_buf(),
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SiteConfig {
pub refuse_markers: Vec<String>,
pub max_concurrent_ceiling: Option<usize>,
}
pub const SITE_ONLY_FIELDS: &[&str] = &["refuse_markers", "max_concurrent_ceiling"];
#[cfg(test)]
fn _every_policy_field_is_classified(site: &SiteConfig) {
let SiteConfig {
refuse_markers: _,
max_concurrent_ceiling: _,
} = site;
}
#[derive(Debug, Error)]
pub enum SiteConfigError {
#[error(
"could not read the site policy file {0}: {1}; `drep check` refuses to run rather than \
report an unenforced policy as compliance"
)]
Read(PathBuf, std::io::Error),
#[error(
"could not parse the site policy file {0}: {1}; `drep check` refuses to run rather than \
report an unenforced policy as compliance"
)]
Parse(PathBuf, String),
#[error(
"the site policy file {0} sets max_concurrent_ceiling = 0, which would leave every \
provider unable to make a request; it must be at least 1"
)]
ZeroConcurrencyCeiling(PathBuf),
#[error(
"the site policy file {path} lists `{marker}` in refuse_markers, which is not a filename; \
each marker names one file to look for, such as `.drep-no-llm`"
)]
UnusableRefuseMarker { path: PathBuf, marker: String },
#[error(
"the site policy file {path} names refuse_markers, but the repository root above {root} \
could not be resolved: {cause}; `drep check` refuses to run rather than report an \
unenforced policy as compliance"
)]
MarkerRootUnresolved {
path: PathBuf,
root: PathBuf,
cause: crate::diff::GitError,
},
#[error(
"the site policy file {path} names the marker {marker}, but its presence could not be \
checked: {cause}; `drep check` refuses to run rather than report an unenforced policy \
as compliance"
)]
MarkerUnreadable {
path: PathBuf,
marker: PathBuf,
cause: std::io::Error,
},
}
#[derive(Debug, Clone)]
pub struct Refusal {
pub marker: PathBuf,
pub policy: PathBuf,
}
pub fn load(path: &Path) -> Result<Option<SiteConfig>, SiteConfigError> {
match std::fs::symlink_metadata(path) {
Ok(_) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(SiteConfigError::Read(path.to_path_buf(), err)),
}
let content = std::fs::read_to_string(path)
.map_err(|err| SiteConfigError::Read(path.to_path_buf(), err))?;
let site: SiteConfig = toml::from_str(&content).map_err(|err: toml::de::Error| {
SiteConfigError::Parse(path.to_path_buf(), err.message().to_owned())
})?;
validate(&site, path)?;
Ok(Some(site))
}
fn validate(site: &SiteConfig, path: &Path) -> Result<(), SiteConfigError> {
if site.max_concurrent_ceiling == Some(0) {
return Err(SiteConfigError::ZeroConcurrencyCeiling(path.to_path_buf()));
}
for marker in &site.refuse_markers {
if !names_one_file(marker) {
return Err(SiteConfigError::UnusableRefuseMarker {
path: path.to_path_buf(),
marker: marker.clone(),
});
}
}
Ok(())
}
fn names_one_file(candidate: &str) -> bool {
let mut components = Path::new(candidate).components();
let first = components.next();
components.next().is_none()
&& matches!(first, Some(Component::Normal(name)) if name.to_str() == Some(candidate))
}
impl SiteConfig {
pub fn clamp_concurrency(&self, requested: usize) -> usize {
match self.max_concurrent_ceiling {
Some(ceiling) => requested.min(ceiling),
None => requested,
}
}
pub fn apply(&self, config: &mut Config) {
for llm in config.llm.iter_mut().filter(|llm| llm.enabled) {
llm.max_concurrent = self.clamp_concurrency(llm.max_concurrent);
}
}
pub async fn refusal_among(
&self,
directories: &BTreeSet<PathBuf>,
policy: &Path,
) -> Result<Option<Refusal>, SiteConfigError> {
if self.refuse_markers.is_empty() {
return Ok(None);
}
let mut resolved = futures::stream::iter(directories)
.map(|directory| async move {
crate::diff::repository_root(directory)
.await
.map_err(|cause| SiteConfigError::MarkerRootUnresolved {
path: policy.to_path_buf(),
root: directory.to_path_buf(),
cause,
})
})
.buffered(ROOT_RESOLUTION_CONCURRENCY);
let mut probed: BTreeSet<PathBuf> = BTreeSet::new();
while let Some(outcome) = resolved.next().await {
let repository_root = outcome?;
if !probed.insert(repository_root.clone()) {
continue;
}
if let Some(refusal) = self.marker_at(&repository_root, policy)? {
return Ok(Some(refusal));
}
}
Ok(None)
}
pub fn has_refuse_markers(&self) -> bool {
!self.refuse_markers.is_empty()
}
fn marker_at(
&self,
repository_root: &Path,
policy: &Path,
) -> Result<Option<Refusal>, SiteConfigError> {
for marker in &self.refuse_markers {
let candidate = repository_root.join(marker);
match std::fs::symlink_metadata(&candidate) {
Ok(_) => {
return Ok(Some(Refusal {
marker: candidate,
policy: policy.to_path_buf(),
}));
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(cause) => {
return Err(SiteConfigError::MarkerUnreadable {
path: policy.to_path_buf(),
marker: candidate,
cause,
});
}
}
}
Ok(None)
}
}