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, because `Config` has no `deny_unknown_fields`. That
168/// is the one outcome [`super::ConfigError::SiteOnlyField`] exists to prevent, and
169/// it would have been reintroduced by the ordinary act of adding a policy field.
170///
171/// Both fields are rejected in `drep.toml`. A repository can already lower its
172/// own `max_concurrent`, but silently dropping the ceiling spelling makes a
173/// developer believe a cross-provider policy is active when it is not.
174pub const SITE_ONLY_FIELDS: &[&str] = &["refuse_markers", "max_concurrent_ceiling"];
175
176/// Fails to compile when a field is added to [`SiteConfig`] without a decision
177/// about whether `drep.toml` may state it.
178///
179/// The exhaustive destructure is the whole point, and is the idiom `KeySource::ALL`
180/// and `Severity::ALL` use for the same purpose: a list that has to be kept in
181/// step with a type by hand is a list that drifts silently, and the drift here
182/// ships source. Adding a field breaks this function, and the fix is one line in
183/// [`SITE_ONLY_FIELDS`] or one name added below.
184#[cfg(test)]
185fn _every_policy_field_is_classified(site: &SiteConfig) {
186 let SiteConfig {
187 // Site-only: named in SITE_ONLY_FIELDS.
188 refuse_markers: _,
189 // Site-only: named in SITE_ONLY_FIELDS.
190 max_concurrent_ceiling: _,
191 } = site;
192}
193
194/// What went wrong loading the site policy file.
195///
196/// A separate enum from [`super::ConfigError`], so the error's *type* names
197/// which of the two files is at fault. Folding these into `ConfigError::Io` and
198/// `ConfigError::Parse` would make those variants reachable from two files with
199/// two different grammars, and a reader could no longer tell which grammar was
200/// violated from the variant alone.
201#[derive(Debug, Error)]
202pub enum SiteConfigError {
203 #[error(
204 "could not read the site policy file {0}: {1}; `drep check` refuses to run rather than \
205 report an unenforced policy as compliance"
206 )]
207 Read(PathBuf, std::io::Error),
208
209 #[error(
210 "could not parse the site policy file {0}: {1}; `drep check` refuses to run rather than \
211 report an unenforced policy as compliance"
212 )]
213 Parse(PathBuf, String),
214
215 /// The clamp runs after `super::validate`, so a ceiling of zero would slip
216 /// past `ConfigError::ZeroConcurrency` and rebuild the hang it exists to
217 /// prevent: a semaphore with no permits, waited on forever with no message.
218 /// Rejected here so the clamp can never produce zero.
219 #[error(
220 "the site policy file {0} sets max_concurrent_ceiling = 0, which would leave every \
221 provider unable to make a request; it must be at least 1"
222 )]
223 ZeroConcurrencyCeiling(PathBuf),
224
225 /// A marker that cannot name a file matches nothing, so the policy
226 /// declaring it refuses nothing while reading as though it did.
227 #[error(
228 "the site policy file {path} lists `{marker}` in refuse_markers, which is not a filename; \
229 each marker names one file to look for, such as `.drep-no-llm`"
230 )]
231 UnusableRefuseMarker { path: PathBuf, marker: String },
232
233 /// Fails closed. A policy naming markers cannot be evaluated outside a
234 /// repository, and "cannot be evaluated" must not become "evaluates to
235 /// allowed": that is the unenforced policy reported as compliance which
236 /// every message here refuses. `cause` rather than `source` because
237 /// `main.rs` prints `{err:#}`, which would otherwise print it twice.
238 #[error(
239 "the site policy file {path} names refuse_markers, but the repository root above {root} \
240 could not be resolved: {cause}; `drep check` refuses to run rather than report an \
241 unenforced policy as compliance"
242 )]
243 MarkerRootUnresolved {
244 path: PathBuf,
245 root: PathBuf,
246 cause: crate::diff::GitError,
247 },
248
249 /// Only `NotFound` means the marker is absent. Every other metadata error
250 /// means the policy could not be evaluated and must fail closed.
251 #[error(
252 "the site policy file {path} names the marker {marker}, but its presence could not be \
253 checked: {cause}; `drep check` refuses to run rather than report an unenforced policy \
254 as compliance"
255 )]
256 MarkerUnreadable {
257 path: PathBuf,
258 marker: PathBuf,
259 cause: std::io::Error,
260 },
261}
262
263/// A repository the site policy refuses to have reviewed by a model.
264///
265/// Carries both paths because the message has to answer two questions at once: a
266/// developer who has never seen this needs to know which file caused it, and
267/// that it came from machine policy rather than a broken install.
268#[derive(Debug, Clone)]
269pub struct Refusal {
270 /// The marker as found, at the repository root.
271 pub marker: PathBuf,
272 /// The policy file that named it.
273 pub policy: PathBuf,
274}
275
276/// Read the policy at `path`.
277///
278/// `Ok(None)` means there is no policy on this machine, which is the ordinary
279/// state and the reason the return type is an `Option` rather than a
280/// `SiteConfig` with empty fields: a caller cannot then confuse "no policy" with
281/// "a policy that permits everything", and the two states print differently in
282/// `doctor`.
283pub fn load(path: &Path) -> Result<Option<SiteConfig>, SiteConfigError> {
284 // Inspect the directory entry without following it before reading. A
285 // missing name is no policy; a dangling symlink is an installed policy
286 // that cannot be read. Letting `read_to_string` collapse both through its
287 // followed-target `NotFound` result switches enforcement off while the
288 // machine path still visibly contains a policy entry.
289 match std::fs::symlink_metadata(path) {
290 Ok(_) => {}
291 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
292 Err(err) => return Err(SiteConfigError::Read(path.to_path_buf(), err)),
293 }
294 let content = std::fs::read_to_string(path)
295 .map_err(|err| SiteConfigError::Read(path.to_path_buf(), err))?;
296 let site: SiteConfig = toml::from_str(&content).map_err(|err: toml::de::Error| {
297 SiteConfigError::Parse(path.to_path_buf(), err.message().to_owned())
298 })?;
299 validate(&site, path)?;
300 Ok(Some(site))
301}
302
303/// Reject what serde cannot enforce from the type alone.
304///
305/// Rejects rather than repairs, following the house rule `ConfigError` states:
306/// a ceiling silently bumped to 1, or a marker silently dropped, is a policy
307/// doing something other than what the administrator wrote.
308fn validate(site: &SiteConfig, path: &Path) -> Result<(), SiteConfigError> {
309 if site.max_concurrent_ceiling == Some(0) {
310 return Err(SiteConfigError::ZeroConcurrencyCeiling(path.to_path_buf()));
311 }
312 for marker in &site.refuse_markers {
313 if !names_one_file(marker) {
314 return Err(SiteConfigError::UnusableRefuseMarker {
315 path: path.to_path_buf(),
316 marker: marker.clone(),
317 });
318 }
319 }
320 Ok(())
321}
322
323/// Whether `candidate` is a single filename and nothing else.
324///
325/// Compared back against the original string so a spelling the platform
326/// normalises away - `"marker/"`, which parses to one component named `marker` -
327/// is rejected too. The alternative is accepting a string that names a file
328/// other than the one written down.
329fn names_one_file(candidate: &str) -> bool {
330 let mut components = Path::new(candidate).components();
331 let first = components.next();
332 components.next().is_none()
333 && matches!(first, Some(Component::Normal(name)) if name.to_str() == Some(candidate))
334}
335
336impl SiteConfig {
337 /// `requested`, lowered to the ceiling when there is one.
338 ///
339 /// The single definition of the rule, so the loaded-config path and
340 /// `doctor`'s raw-tree path cannot come to disagree about the same entry -
341 /// the shape `auth::source_of` already exists in for the same reason.
342 pub fn clamp_concurrency(&self, requested: usize) -> usize {
343 match self.max_concurrent_ceiling {
344 Some(ceiling) => requested.min(ceiling),
345 None => requested,
346 }
347 }
348
349 /// Lower every enabled provider's `max_concurrent` to the ceiling.
350 ///
351 /// Disabled entries are skipped, matching every other pass over the provider
352 /// list: `${VAR}` expansion, field validation and `auth::resolve` all leave a
353 /// parked entry alone, and clamping one would make `doctor` report a change
354 /// to a provider drep never contacts.
355 ///
356 /// Applied to the *effective* value, whether the repository wrote
357 /// `max_concurrent` or inherited the default. Skipping the defaulted ones
358 /// would let a repository raise its own concurrency by deleting a line, which
359 /// is the loosening this layer exists to prevent.
360 ///
361 /// Returns nothing: no caller wants a list of what it changed, and an unread
362 /// return value is surface a later reader has to account for.
363 pub fn apply(&self, config: &mut Config) {
364 for llm in config.llm.iter_mut().filter(|llm| llm.enabled) {
365 llm.max_concurrent = self.clamp_concurrency(llm.max_concurrent);
366 }
367 }
368
369 /// The first configured marker present at the repository root above any of
370 /// `directories`.
371 ///
372 /// `Ok(None)` on a machine that configured none, decided before git is
373 /// spawned. That short circuit is the whole reason an unaffected machine
374 /// gains neither the latency nor the new failure mode: `drep check` outside a
375 /// repository keeps working exactly as it does today, and only a machine that
376 /// asked for the policy pays for evaluating it.
377 ///
378 /// The repository root, not the directory itself: a check run from a
379 /// subdirectory of a marked repository is still a check on that repository's
380 /// source, and consulting the given directory would let `cd src && drep
381 /// check` walk straight past the policy.
382 ///
383 /// Plural because one run can review files from more than one repository, and
384 /// then one repository's policy was consulted while another's source was
385 /// sent. Each directory is resolved on its own rather than being assumed to
386 /// share a root with the others: a nested checkout has its own root, and
387 /// deciding otherwise from the paths alone would reimplement git's discovery
388 /// rules here. The marker probe is then done once per distinct root.
389 ///
390 /// Presence is decided by [`std::fs::symlink_metadata`], and nothing opens
391 /// the file. Not `metadata`, which follows a symlink and so answers "no" for
392 /// a marker whose target is gone; not `is_file()`, which answers "no" for a
393 /// directory. Both are names someone deliberately placed at the root, and
394 /// either reading would let a marker silently disable the policy it was put
395 /// there to invoke. Contents are never read for the same reason: a marker
396 /// whose text said `allow` would be a second grammar nobody documented.
397 pub async fn refusal_among(
398 &self,
399 directories: &BTreeSet<PathBuf>,
400 policy: &Path,
401 ) -> Result<Option<Refusal>, SiteConfigError> {
402 if self.refuse_markers.is_empty() {
403 return Ok(None);
404 }
405
406 // The roots resolve concurrently, bounded the way every other spawn fan-out
407 // in this crate is bounded: `check::deterministic` runs its tool processes
408 // through `buffer_unordered(TOOL_PROCESS_CONCURRENCY)` for the same reason.
409 // Sequentially, this was one `git rev-parse --show-toplevel` per reviewed
410 // directory at ~18ms each, added to every commit on a policy machine, and
411 // the dedup below does not reduce it: `probed` dedups on the *resolved*
412 // root, so it saves the marker stat, not the spawn. The refused case was
413 // cheap by luck and the permitted case - which is every commit - paid all
414 // of them.
415 //
416 // `buffered` resolves concurrently but yields in the `BTreeSet`'s stable
417 // order, so the first failure and first marker remain the same ones a
418 // sequential walk would have found. Consume incrementally: an early
419 // refusal or error can drop the remaining in-flight work instead of
420 // waiting for every repository query and storing every result.
421 let mut resolved = futures::stream::iter(directories)
422 .map(|directory| async move {
423 crate::diff::repository_root(directory)
424 .await
425 .map_err(|cause| SiteConfigError::MarkerRootUnresolved {
426 path: policy.to_path_buf(),
427 root: directory.to_path_buf(),
428 cause,
429 })
430 })
431 .buffered(ROOT_RESOLUTION_CONCURRENCY);
432
433 let mut probed: BTreeSet<PathBuf> = BTreeSet::new();
434 while let Some(outcome) = resolved.next().await {
435 let repository_root = outcome?;
436 if !probed.insert(repository_root.clone()) {
437 continue;
438 }
439 if let Some(refusal) = self.marker_at(&repository_root, policy)? {
440 return Ok(Some(refusal));
441 }
442 }
443 Ok(None)
444 }
445
446 /// Whether this policy names any marker at all.
447 ///
448 /// So a caller can decline to build the directory set for a policy that would
449 /// return immediately. The guard inside [`Self::refusal_among`] stays: this one
450 /// saves the argument, not the answer.
451 pub fn has_refuse_markers(&self) -> bool {
452 !self.refuse_markers.is_empty()
453 }
454
455 /// The first configured marker present at one repository root.
456 fn marker_at(
457 &self,
458 repository_root: &Path,
459 policy: &Path,
460 ) -> Result<Option<Refusal>, SiteConfigError> {
461 for marker in &self.refuse_markers {
462 let candidate = repository_root.join(marker);
463 match std::fs::symlink_metadata(&candidate) {
464 Ok(_) => {
465 return Ok(Some(Refusal {
466 marker: candidate,
467 policy: policy.to_path_buf(),
468 }));
469 }
470 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
471 Err(cause) => {
472 return Err(SiteConfigError::MarkerUnreadable {
473 path: policy.to_path_buf(),
474 marker: candidate,
475 cause,
476 });
477 }
478 }
479 }
480 Ok(None)
481 }
482}