drep/config/site.rs
1//! The machine-level site policy layer above `drep.toml`.
2//!
3//! `drep.toml` is a repository file, and `drep init` adds it to `.gitignore` by
4//! default - so it is per-developer scratch, and a control written there is
5//! opt-in. Opt-in means off for the person who most needs it. This layer sits
6//! above it: a repository checkout can tighten what the site allows, never
7//! loosen it.
8//!
9//! Three rules carry the whole module.
10//!
11//! - **A missing file is no policy, and is not an error.** Most machines have
12//! none. [`load`] returns `Option<SiteConfig>` so that is structural rather
13//! than a convention a caller could get backwards.
14//! - **A file that exists and cannot be loaded is fatal.** A policy that
15//! silently fails to load is worse than no policy at all, because the
16//! unconstrained run that follows reports as compliance. Every
17//! [`SiteConfigError`] says so in its own message.
18//! - **The file is not per-user, and the process it constrains cannot move it.**
19//! The location is a system path rather than the `ProjectDirs` directory holding
20//! `auth.toml` and the response cache, because a policy file the policed
21//! developer can edit without privilege is not a policy file. [`PATH_VAR`] names
22//! the file only on a machine where none is installed, for the same reason: an
23//! override that could displace an installed policy would be one `export` away
24//! from switching it off. There is no `${VAR}` expansion in the file either - a
25//! policy that takes its values from the environment of the process it
26//! constrains constrains nothing.
27//!
28//! The layering is applied by the caller, after [`super::load`] returns, which
29//! is what keeps [`super::ConfigError`] a statement about `drep.toml` alone.
30
31use std::collections::BTreeSet;
32use std::path::{Component, Path, PathBuf};
33
34use futures::StreamExt;
35use serde::Deserialize;
36use thiserror::Error;
37
38use super::Config;
39
40/// The environment variable that relocates the policy file.
41pub const PATH_VAR: &str = "DREP_SITE_CONFIG";
42
43/// How many repository roots resolve at once.
44///
45/// Four, matching `check::deterministic`'s `TOOL_PROCESS_CONCURRENCY`, because it
46/// bounds the same resource: short-lived child processes on a developer machine
47/// that is probably also compiling. Its own constant rather than a shared one,
48/// since the two fan-outs spawn different programs and would be tuned apart.
49const ROOT_RESOLUTION_CONCURRENCY: usize = 4;
50
51/// The machine-wide policy path, per platform.
52///
53/// Deliberately not under `directories::ProjectDirs`, where `auth.toml` and the
54/// cache live: those are the user's own state and belong in the user's own
55/// directory, while this file is the thing the user is not supposed to be able
56/// to edit. The system path uses the plain `drep` name rather than drep's
57/// `dev.slb350.drep` identity triple because an administrator installs it by
58/// hand, and a reverse-DNS directory under `/etc` is a path nobody can type.
59///
60/// A `const` with two `cfg` arms rather than a `cfg`'d pair of functions: a
61/// function body that is not compiled on this platform is a mutation the test
62/// suite can never detect, which `auth::restrict` records. A const is not a
63/// mutation target at all.
64#[cfg(target_os = "macos")]
65const MACHINE_PATH: &str = "/Library/Application Support/drep/site.toml";
66#[cfg(not(target_os = "macos"))]
67const MACHINE_PATH: &str = "/etc/drep/site.toml";
68
69/// The policy path: the machine-wide file, or [`PATH_VAR`] when there is none.
70pub fn default_path() -> PathBuf {
71 path_from(std::env::var_os(PATH_VAR), machine_path())
72}
73
74/// The machine-wide path this platform installs policy at.
75///
76/// A function so `default_path` and its test read one thing rather than each
77/// restating the literal, and so the two `cfg` arms above have a single reader.
78pub fn machine_path() -> &'static Path {
79 Path::new(MACHINE_PATH)
80}
81
82/// [`default_path`] with the override and the machine path supplied rather than
83/// read.
84///
85/// Split out for the reason `auth::path_from` is: `std::env::set_var` is
86/// `unsafe` in edition 2024 because another thread reading the environment is a
87/// data race, and `cargo test` is multi-threaded, so the override has to be
88/// suppliable to be testable at all. The machine path joins it because a test
89/// cannot write to `/etc` either.
90///
91/// **The override cannot displace an installed policy.** If a file exists at
92/// `machine`, that file is the policy and the variable is ignored. Otherwise the
93/// whole layer is one `export` away from off: the developer `refuse_markers`
94/// constrains points the variable at an empty file, the marker list is empty, the
95/// probe short-circuits before git is even spawned, and the run sends the
96/// repository's source and exits 0. `ConfigError::SiteOnlyField` refuses that
97/// field in `drep.toml` because a refusal a developer can delete is not one, and a
98/// per-process override is a way to delete it. The precedence is not silent
99/// either: `drep doctor` names the file in effect, so an administrator who moved
100/// the policy and left the old one behind can see which one answered.
101///
102/// So the variable names the policy on a machine that installed none - an
103/// installation that keeps the file elsewhere, and every test in this suite, which
104/// must not read whatever this machine happens to hold. An **empty** value falls
105/// back to the machine path rather than being honoured as a file that does not
106/// exist, because `DREP_SITE_CONFIG=` quietly switching enforcement off is the
107/// same defect as a policy file that fails to load.
108///
109/// Presence is [`std::fs::symlink_metadata`], matching the marker probe: a name
110/// someone deliberately placed is a name claiming to be the policy, and following
111/// the link would let a dangling symlink hand the decision back to the
112/// environment.
113///
114/// Infallible, unlike `auth::path_from`, because no `ProjectDirs` lookup is
115/// involved and a system path exists on every platform drep ships to.
116pub fn path_from(overridden: Option<std::ffi::OsString>, machine: &Path) -> PathBuf {
117 match overridden {
118 Some(path)
119 if !path.is_empty()
120 && matches!(
121 std::fs::symlink_metadata(machine),
122 Err(ref err) if err.kind() == std::io::ErrorKind::NotFound
123 ) =>
124 {
125 PathBuf::from(path)
126 }
127 _ => machine.to_path_buf(),
128 }
129}
130
131/// What the site allows, for every repository on this machine.
132///
133/// `deny_unknown_fields` is the whole of the "no providers, no credentials"
134/// rule: an `[[llm]]`, an `endpoint` or an `api_key` in this file is an unknown
135/// key and is rejected, so there is no separate rejection list to drift from the
136/// field list. It is also what makes a misspelled policy key loud rather than a
137/// silent no-op.
138///
139/// This is the one config type that may derive `Debug`. `LlmConfig`, `AuthStore`
140/// and `LlmClient` hand-write theirs because they can hold a credential; this
141/// file is defined to carry none, and the attribute above is what enforces that
142/// definition rather than a promise in a comment.
143#[derive(Debug, Default, Deserialize)]
144#[serde(default, deny_unknown_fields)]
145pub struct SiteConfig {
146 /// Filenames whose presence in a repository refuses semantic review.
147 ///
148 /// Consumed by the marker refusal in `check`, which is what reads this list
149 /// and stops the semantic layer before a byte of source is rendered. Parsed
150 /// and validated here, and read nowhere else in this module.
151 pub refuse_markers: Vec<String>,
152
153 /// The most concurrent LLM requests any one provider may make.
154 ///
155 /// An `Option` rather than a `usize` sentinel so `doctor` can tell "no
156 /// ceiling" from "a ceiling that happens to equal the default".
157 pub max_concurrent_ceiling: Option<usize>,
158}
159
160/// The fields of this file that `drep.toml` must not be able to state.
161///
162/// Here rather than in `config.rs` because it is a statement about the fields
163/// declared directly above it, and the decision about a new one belongs where the
164/// field is added. `config::site_only_field` used to be a hard-coded
165/// `tree.get("refuse_markers")` in the other module: a third field added here
166/// would have compiled, said nothing, and been silently dropped from a
167/// `drep.toml` that named it - the one outcome
168/// [`super::ConfigError::SiteOnlyField`] exists to prevent, reintroduced by the
169/// ordinary act of adding a policy field. `Config` has since gained
170/// `deny_unknown_fields`, so the same omission now costs the message rather than
171/// the refusal: the key is rejected as a misspelling, against a list of the two
172/// keys `drep.toml` does take, and a developer reads that as a line to delete
173/// rather than as policy that lives in this file.
174///
175/// Both fields are rejected in `drep.toml`. A repository can already lower its
176/// own `max_concurrent`, but silently dropping the ceiling spelling makes a
177/// developer believe a cross-provider policy is active when it is not.
178pub const SITE_ONLY_FIELDS: &[&str] = &["refuse_markers", "max_concurrent_ceiling"];
179
180/// Fails to compile when a field is added to [`SiteConfig`] without a decision
181/// about whether `drep.toml` may state it.
182///
183/// The exhaustive destructure is the whole point, and is the idiom `KeySource::ALL`
184/// and `Severity::ALL` use for the same purpose: a list that has to be kept in
185/// step with a type by hand is a list that drifts silently, and the drift here
186/// ships source. Adding a field breaks this function, and the fix is one line in
187/// [`SITE_ONLY_FIELDS`] or one name added below.
188#[cfg(test)]
189fn _every_policy_field_is_classified(site: &SiteConfig) {
190 let SiteConfig {
191 // Site-only: named in SITE_ONLY_FIELDS.
192 refuse_markers: _,
193 // Site-only: named in SITE_ONLY_FIELDS.
194 max_concurrent_ceiling: _,
195 } = site;
196}
197
198/// What went wrong loading the site policy file.
199///
200/// A separate enum from [`super::ConfigError`], so the error's *type* names
201/// which of the two files is at fault. Folding these into `ConfigError::Io` and
202/// `ConfigError::Parse` would make those variants reachable from two files with
203/// two different grammars, and a reader could no longer tell which grammar was
204/// violated from the variant alone.
205#[derive(Debug, Error)]
206pub enum SiteConfigError {
207 #[error(
208 "could not read the site policy file {0}: {1}; `drep check` refuses to run rather than \
209 report an unenforced policy as compliance"
210 )]
211 Read(PathBuf, std::io::Error),
212
213 #[error(
214 "could not parse the site policy file {0}: {1}; `drep check` refuses to run rather than \
215 report an unenforced policy as compliance"
216 )]
217 Parse(PathBuf, String),
218
219 /// The clamp runs after `super::validate`, so a ceiling of zero would slip
220 /// past `ConfigError::ZeroConcurrency` and rebuild the hang it exists to
221 /// prevent: a semaphore with no permits, waited on forever with no message.
222 /// Rejected here so the clamp can never produce zero.
223 #[error(
224 "the site policy file {0} sets max_concurrent_ceiling = 0, which would leave every \
225 provider unable to make a request; it must be at least 1"
226 )]
227 ZeroConcurrencyCeiling(PathBuf),
228
229 /// A marker that cannot name a file matches nothing, so the policy
230 /// declaring it refuses nothing while reading as though it did.
231 #[error(
232 "the site policy file {path} lists `{marker}` in refuse_markers, which is not a filename; \
233 each marker names one file to look for, such as `.drep-no-llm`"
234 )]
235 UnusableRefuseMarker { path: PathBuf, marker: String },
236
237 /// Fails closed. A policy naming markers cannot be evaluated outside a
238 /// repository, and "cannot be evaluated" must not become "evaluates to
239 /// allowed": that is the unenforced policy reported as compliance which
240 /// every message here refuses. `cause` rather than `source` because
241 /// `main.rs` prints `{err:#}`, which would otherwise print it twice.
242 #[error(
243 "the site policy file {path} names refuse_markers, but the repository root above {root} \
244 could not be resolved: {cause}; `drep check` refuses to run rather than report an \
245 unenforced policy as compliance"
246 )]
247 MarkerRootUnresolved {
248 path: PathBuf,
249 root: PathBuf,
250 cause: crate::diff::GitError,
251 },
252
253 /// Only `NotFound` means the marker is absent. Every other metadata error
254 /// means the policy could not be evaluated and must fail closed.
255 #[error(
256 "the site policy file {path} names the marker {marker}, but its presence could not be \
257 checked: {cause}; `drep check` refuses to run rather than report an unenforced policy \
258 as compliance"
259 )]
260 MarkerUnreadable {
261 path: PathBuf,
262 marker: PathBuf,
263 cause: std::io::Error,
264 },
265}
266
267/// A repository the site policy refuses to have reviewed by a model.
268///
269/// Carries both paths because the message has to answer two questions at once: a
270/// developer who has never seen this needs to know which file caused it, and
271/// that it came from machine policy rather than a broken install.
272#[derive(Debug, Clone)]
273pub struct Refusal {
274 /// The marker as found, at the repository root.
275 pub marker: PathBuf,
276 /// The policy file that named it.
277 pub policy: PathBuf,
278}
279
280/// Read the policy at `path`.
281///
282/// `Ok(None)` means there is no policy on this machine, which is the ordinary
283/// state and the reason the return type is an `Option` rather than a
284/// `SiteConfig` with empty fields: a caller cannot then confuse "no policy" with
285/// "a policy that permits everything", and the two states print differently in
286/// `doctor`.
287pub fn load(path: &Path) -> Result<Option<SiteConfig>, SiteConfigError> {
288 // Inspect the directory entry without following it before reading. A
289 // missing name is no policy; a dangling symlink is an installed policy
290 // that cannot be read. Letting `read_to_string` collapse both through its
291 // followed-target `NotFound` result switches enforcement off while the
292 // machine path still visibly contains a policy entry.
293 match std::fs::symlink_metadata(path) {
294 Ok(_) => {}
295 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
296 Err(err) => return Err(SiteConfigError::Read(path.to_path_buf(), err)),
297 }
298 let content = std::fs::read_to_string(path)
299 .map_err(|err| SiteConfigError::Read(path.to_path_buf(), err))?;
300 let site: SiteConfig = toml::from_str(&content).map_err(|err: toml::de::Error| {
301 SiteConfigError::Parse(path.to_path_buf(), err.message().to_owned())
302 })?;
303 validate(&site, path)?;
304 Ok(Some(site))
305}
306
307/// Reject what serde cannot enforce from the type alone.
308///
309/// Rejects rather than repairs, following the house rule `ConfigError` states:
310/// a ceiling silently bumped to 1, or a marker silently dropped, is a policy
311/// doing something other than what the administrator wrote.
312fn validate(site: &SiteConfig, path: &Path) -> Result<(), SiteConfigError> {
313 if site.max_concurrent_ceiling == Some(0) {
314 return Err(SiteConfigError::ZeroConcurrencyCeiling(path.to_path_buf()));
315 }
316 for marker in &site.refuse_markers {
317 if !names_one_file(marker) {
318 return Err(SiteConfigError::UnusableRefuseMarker {
319 path: path.to_path_buf(),
320 marker: marker.clone(),
321 });
322 }
323 }
324 Ok(())
325}
326
327/// Whether `candidate` is a single filename and nothing else.
328///
329/// Compared back against the original string so a spelling the platform
330/// normalises away - `"marker/"`, which parses to one component named `marker` -
331/// is rejected too. The alternative is accepting a string that names a file
332/// other than the one written down.
333fn names_one_file(candidate: &str) -> bool {
334 let mut components = Path::new(candidate).components();
335 let first = components.next();
336 components.next().is_none()
337 && matches!(first, Some(Component::Normal(name)) if name.to_str() == Some(candidate))
338}
339
340impl SiteConfig {
341 /// `requested`, lowered to the ceiling when there is one.
342 ///
343 /// The single definition of the rule, so the loaded-config path and
344 /// `doctor`'s raw-tree path cannot come to disagree about the same entry -
345 /// the shape `auth::source_of` already exists in for the same reason.
346 pub fn clamp_concurrency(&self, requested: usize) -> usize {
347 match self.max_concurrent_ceiling {
348 Some(ceiling) => requested.min(ceiling),
349 None => requested,
350 }
351 }
352
353 /// Lower every enabled provider's `max_concurrent` to the ceiling.
354 ///
355 /// Disabled entries are skipped, matching every other pass over the provider
356 /// list: `${VAR}` expansion, field validation and `auth::resolve` all leave a
357 /// parked entry alone, and clamping one would make `doctor` report a change
358 /// to a provider drep never contacts.
359 ///
360 /// Applied to the *effective* value, whether the repository wrote
361 /// `max_concurrent` or inherited the default. Skipping the defaulted ones
362 /// would let a repository raise its own concurrency by deleting a line, which
363 /// is the loosening this layer exists to prevent.
364 ///
365 /// Returns nothing: no caller wants a list of what it changed, and an unread
366 /// return value is surface a later reader has to account for.
367 pub fn apply(&self, config: &mut Config) {
368 for llm in config.llm.iter_mut().filter(|llm| llm.enabled) {
369 llm.max_concurrent = self.clamp_concurrency(llm.max_concurrent);
370 }
371 }
372
373 /// The first configured marker present at the repository root above any of
374 /// `directories`.
375 ///
376 /// `Ok(None)` on a machine that configured none, decided before git is
377 /// spawned. That short circuit is the whole reason an unaffected machine
378 /// gains neither the latency nor the new failure mode: `drep check` outside a
379 /// repository keeps working exactly as it does today, and only a machine that
380 /// asked for the policy pays for evaluating it.
381 ///
382 /// The repository root, not the directory itself: a check run from a
383 /// subdirectory of a marked repository is still a check on that repository's
384 /// source, and consulting the given directory would let `cd src && drep
385 /// check` walk straight past the policy.
386 ///
387 /// Plural because one run can review files from more than one repository, and
388 /// then one repository's policy was consulted while another's source was
389 /// sent. Each directory is resolved on its own rather than being assumed to
390 /// share a root with the others: a nested checkout has its own root, and
391 /// deciding otherwise from the paths alone would reimplement git's discovery
392 /// rules here. The marker probe is then done once per distinct root.
393 ///
394 /// Presence is decided by [`std::fs::symlink_metadata`], and nothing opens
395 /// the file. Not `metadata`, which follows a symlink and so answers "no" for
396 /// a marker whose target is gone; not `is_file()`, which answers "no" for a
397 /// directory. Both are names someone deliberately placed at the root, and
398 /// either reading would let a marker silently disable the policy it was put
399 /// there to invoke. Contents are never read for the same reason: a marker
400 /// whose text said `allow` would be a second grammar nobody documented.
401 pub async fn refusal_among(
402 &self,
403 directories: &BTreeSet<PathBuf>,
404 policy: &Path,
405 ) -> Result<Option<Refusal>, SiteConfigError> {
406 if self.refuse_markers.is_empty() {
407 return Ok(None);
408 }
409
410 // The roots resolve concurrently, bounded the way every other spawn fan-out
411 // in this crate is bounded: `check::deterministic` runs its tool processes
412 // through `buffer_unordered(TOOL_PROCESS_CONCURRENCY)` for the same reason.
413 // Sequentially, this was one `git rev-parse --show-toplevel` per reviewed
414 // directory at ~18ms each, added to every commit on a policy machine, and
415 // the dedup below does not reduce it: `probed` dedups on the *resolved*
416 // root, so it saves the marker stat, not the spawn. The refused case was
417 // cheap by luck and the permitted case - which is every commit - paid all
418 // of them.
419 //
420 // `buffered` resolves concurrently but yields in the `BTreeSet`'s stable
421 // order, so the first failure and first marker remain the same ones a
422 // sequential walk would have found. Consume incrementally: an early
423 // refusal or error can drop the remaining in-flight work instead of
424 // waiting for every repository query and storing every result.
425 let mut resolved = futures::stream::iter(directories)
426 .map(|directory| async move {
427 crate::diff::repository_root(directory)
428 .await
429 .map_err(|cause| SiteConfigError::MarkerRootUnresolved {
430 path: policy.to_path_buf(),
431 root: directory.to_path_buf(),
432 cause,
433 })
434 })
435 .buffered(ROOT_RESOLUTION_CONCURRENCY);
436
437 let mut probed: BTreeSet<PathBuf> = BTreeSet::new();
438 while let Some(outcome) = resolved.next().await {
439 let repository_root = outcome?;
440 if !probed.insert(repository_root.clone()) {
441 continue;
442 }
443 if let Some(refusal) = self.marker_at(&repository_root, policy)? {
444 return Ok(Some(refusal));
445 }
446 }
447 Ok(None)
448 }
449
450 /// Whether this policy names any marker at all.
451 ///
452 /// So a caller can decline to build the directory set for a policy that would
453 /// return immediately. The guard inside [`Self::refusal_among`] stays: this one
454 /// saves the argument, not the answer.
455 pub fn has_refuse_markers(&self) -> bool {
456 !self.refuse_markers.is_empty()
457 }
458
459 /// The first configured marker present at one repository root.
460 fn marker_at(
461 &self,
462 repository_root: &Path,
463 policy: &Path,
464 ) -> Result<Option<Refusal>, SiteConfigError> {
465 for marker in &self.refuse_markers {
466 let candidate = repository_root.join(marker);
467 match std::fs::symlink_metadata(&candidate) {
468 Ok(_) => {
469 return Ok(Some(Refusal {
470 marker: candidate,
471 policy: policy.to_path_buf(),
472 }));
473 }
474 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
475 Err(cause) => {
476 return Err(SiteConfigError::MarkerUnreadable {
477 path: policy.to_path_buf(),
478 marker: candidate,
479 cause,
480 });
481 }
482 }
483 }
484 Ok(None)
485 }
486}