Skip to main content

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}