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 - 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}