Skip to main content

onevcs/
boundary.rs

1//! The public boundary: what may leave this host for a public destination.
2//!
3//! Private work is allowed to inform public repositories — as generic examples —
4//! and the one thing it may not do on the way is carry the name of where it came
5//! from. Every write this crate makes to a public destination is therefore put
6//! through one check first: the text, paths and metadata it is about to publish are
7//! matched against the **terms** of the private repositories the check's
8//! [`TermScope`] selects, and a hit refuses the write before anything reaches the
9//! remote.
10//!
11//! This module holds the shapes that check is described in — they are the
12//! authoritative definitions `docs/contract.md` reconciles against — and the two
13//! operations a caller asks it through: [`check_public_output`] and
14//! [`repository_boundary`].
15//!
16//! # Visibility
17//!
18//! A repository is [`Visibility::Public`], [`Visibility::Private`] or
19//! [`Visibility::Unknown`], and **only `public` is public**: an unknown repository,
20//! and a local-only one with no host to ask, is treated as private — so it
21//! contributes terms, and a write to it is not a public write. GitHub's answer is
22//! probed and recorded in the registry; a rule's `visibility:` overrides it and is
23//! never probed over.
24//!
25//! # Terms
26//!
27//! A private repository's terms are derived from its **committed** tree — never a
28//! worktree — in the registered checkout:
29//!
30//! 1. `owner/name`, case-insensitively and anywhere, which covers every URL form of it;
31//! 2. its bare name and the package names its manifests declare, as whole words;
32//! 3. a bare or package term that is also a public repository's name, the login of an
33//!    owner with a public repository, or a word in the shipped generic-word list is
34//!    narrowed to the fully qualified `owner/name` alone;
35//! 4. its owner, as a whole word — unless that owner also owns a public repository.
36//!
37//! A repository may commit a [`PRIVATE_TERMS_FILE`] adding terms and declaring
38//! exceptions to the derived ones. The manifests read are Cargo's `[package]` and
39//! `[workspace].members`, `package.json`'s `name` and `workspaces`, and
40//! `pyproject.toml`'s `[project]` and `[tool.poetry]` names; a member glob may use `*`
41//! within a path segment.
42
43use std::collections::BTreeSet;
44use std::path::Path;
45
46use schemars::JsonSchema;
47use serde::{Deserialize, Deserializer, Serialize, Serializer};
48
49use crate::error::{Error, Result};
50use crate::providers::Providers;
51
52mod derive;
53pub(crate) mod diagnostics;
54pub(crate) mod evidence;
55mod manifests;
56mod matcher;
57pub(crate) mod scope;
58pub(crate) mod screen;
59pub(crate) mod visibility;
60mod words;
61
62/// The file a repository commits at its root to add terms and declare exceptions.
63pub const PRIVATE_TERMS_FILE: &str = "private-terms.toml";
64
65/// The `schema_version` a [`PrivateTerms`] declaration is written at, and the only
66/// one this build reads.
67pub const PRIVATE_TERMS_VERSION: u32 = 1;
68
69/// The version of the JSON shapes `onevcs boundary inspect` and `onevcs boundary
70/// check` exchange, which [`boundary_schema`] carries.
71pub const BOUNDARY_SCHEMA_VERSION: u32 = 1;
72
73/// Whether a repository is public.
74///
75/// Only [`Public`](Visibility::Public) is: see [`effective`](Visibility::effective).
76#[derive(
77    Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
78)]
79#[serde(rename_all = "lowercase")]
80pub enum Visibility {
81    /// Anybody can read it.
82    Public,
83    /// It is private, or internal to an organisation.
84    Private,
85    /// Nobody has said. Treated as private.
86    #[default]
87    Unknown,
88}
89
90impl Visibility {
91    /// What this visibility is treated as: `public` for public and `private` for
92    /// everything else, an unknown repository included.
93    pub fn effective(self) -> Visibility {
94        match self {
95            Visibility::Public => Visibility::Public,
96            Visibility::Private | Visibility::Unknown => Visibility::Private,
97        }
98    }
99
100    /// Whether this is [`Visibility::Unknown`], which a registry identity omits.
101    pub fn is_unknown(&self) -> bool {
102        *self == Visibility::Unknown
103    }
104}
105
106/// Where a registry identity's recorded [`Visibility`] came from.
107#[derive(
108    Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
109)]
110#[serde(rename_all = "lowercase")]
111pub enum Observation {
112    /// The host answered it.
113    Host,
114    /// A rule's `visibility:` configured it, and the host was not asked.
115    Override,
116    /// Nothing answered: never asked, a local-only repository, or a probe that
117    /// failed — which is recorded as this rather than as the last answer.
118    #[default]
119    Unknown,
120}
121
122impl Observation {
123    /// Whether this is [`Observation::Unknown`], which a registry identity omits.
124    pub fn is_unknown(&self) -> bool {
125        *self == Observation::Unknown
126    }
127}
128
129/// How a [`TermRule`]'s term is found in text.
130#[derive(
131    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
132)]
133#[serde(rename_all = "kebab-case")]
134pub enum TermMode {
135    /// Anywhere, inside a longer word included.
136    Substring,
137    /// Only with no letter, digit or `_` on either side.
138    WholeWord,
139    /// The term is a fully qualified `owner/name`, found only where that qualified
140    /// name stands on its own (`.git` after it included).
141    OwnerNameOnly,
142}
143
144/// One term and how it is matched.
145#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
146#[serde(deny_unknown_fields)]
147pub struct TermRule {
148    /// The text looked for.
149    pub term: String,
150    /// How it is looked for.
151    pub mode: TermMode,
152    /// Whether case must match too. Unset is `false`.
153    #[serde(default)]
154    pub case_sensitive: bool,
155}
156
157/// Which private repositories a check derives its terms from.
158///
159/// On the wire it is the optional `scope` of a [`BoundaryInput`]: absent is
160/// [`Registry`](TermScope::Registry), and an array of identities is
161/// [`Identities`](TermScope::Identities).
162#[derive(Debug, Clone, Default, PartialEq, Eq)]
163pub enum TermScope {
164    /// Every registered identity whose effective visibility is private.
165    #[default]
166    Registry,
167    /// The named identities whose effective visibility is private, and no other. A
168    /// named public identity contributes nothing; a named identity that is not
169    /// registered, or whose committed declarations cannot be read, makes the check
170    /// unavailable. Empty derives nothing and reads nothing.
171    Identities(Vec<String>),
172}
173
174impl TermScope {
175    /// Whether this is [`TermScope::Registry`], which the wire spells by absence.
176    pub fn is_registry(&self) -> bool {
177        *self == TermScope::Registry
178    }
179}
180
181impl Serialize for TermScope {
182    fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
183        match self {
184            TermScope::Registry => serializer.serialize_none(),
185            TermScope::Identities(identities) => identities.serialize(serializer),
186        }
187    }
188}
189
190impl<'de> Deserialize<'de> for TermScope {
191    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
192        Ok(match Option::<Vec<String>>::deserialize(deserializer)? {
193            None => TermScope::Registry,
194            Some(identities) => TermScope::Identities(identities),
195        })
196    }
197}
198
199/// What a caller is about to write somewhere, put to the check.
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
201#[serde(deny_unknown_fields)]
202pub struct BoundaryInput {
203    /// Where it is going. Anything but `public` passes without a term being read.
204    pub destination: Visibility,
205    /// Prose and file contents.
206    #[serde(default)]
207    pub text: Vec<String>,
208    /// Paths it names or writes.
209    #[serde(default)]
210    pub paths: Vec<String>,
211    /// Branch names, titles, commit messages and every other short field.
212    #[serde(default)]
213    pub metadata: Vec<String>,
214    /// Which private repositories the terms come from.
215    #[serde(default, skip_serializing_if = "TermScope::is_registry")]
216    #[schemars(with = "Option<Vec<String>>")]
217    pub scope: TermScope,
218}
219
220/// Where in a write a private term was found — the whole of what a refusal says
221/// about it.
222#[derive(
223    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
224)]
225#[serde(rename_all = "kebab-case")]
226pub enum Surface {
227    /// A [`BoundaryInput::text`] entry.
228    Text,
229    /// A [`BoundaryInput::paths`] entry, a path a publication adds, or a path an
230    /// export writes.
231    Path,
232    /// A [`BoundaryInput::metadata`] entry.
233    Metadata,
234    /// A line a publication adds, or a file an export writes.
235    Content,
236    /// A line or a path a publication removes that its destination does not already
237    /// carry there.
238    Removal,
239    /// An outgoing commit's message.
240    CommitMessage,
241    /// The branch the write is made on.
242    Branch,
243    /// A change request's title.
244    Title,
245    /// A change request's body.
246    Body,
247}
248
249impl Surface {
250    /// How a refusal names it.
251    pub fn describe(self) -> &'static str {
252        match self {
253            Surface::Text => "its text",
254            Surface::Path => "a path",
255            Surface::Metadata => "its metadata",
256            Surface::Content => "an added line",
257            Surface::Removal => "a removal its public destination does not already carry",
258            Surface::CommitMessage => "a commit message",
259            Surface::Branch => "its branch name",
260            Surface::Title => "its title",
261            Surface::Body => "its body",
262        }
263    }
264}
265
266/// Why a check could not decide. Never a pass.
267#[derive(
268    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
269)]
270#[serde(rename_all = "kebab-case")]
271pub enum Unavailability {
272    /// The registry could not be read.
273    Registry,
274    /// The rules file could not be read.
275    Rules,
276    /// A scoped identity is not registered.
277    Unregistered,
278    /// A private identity's committed manifests or term declaration could not be
279    /// read, or declare something this build refuses.
280    Declarations,
281    /// The terms could not be compiled into one matcher.
282    Matcher,
283    /// What is being published could not be read out of the repository.
284    History,
285}
286
287impl Unavailability {
288    /// How a refusal names it.
289    pub fn describe(self) -> &'static str {
290        match self {
291            Unavailability::Registry => "the registry could not be read",
292            Unavailability::Rules => "the rules file could not be read",
293            Unavailability::Unregistered => "a repository in its term scope is not registered",
294            Unavailability::Declarations => {
295                "a private repository's committed manifests or term declaration could not be read"
296            }
297            Unavailability::Matcher => "the terms could not be compiled",
298            Unavailability::History => "what is being published could not be read",
299        }
300    }
301}
302
303/// What the check decided.
304///
305/// The public-facing half carries no term, no identity and no path: a refusal says
306/// *where* a term was found and nothing about which. The detail is
307/// [`Evidence`], which only a caller that supplied somewhere private to put it gets.
308#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
309#[serde(tag = "verdict", rename_all = "kebab-case")]
310pub enum BoundaryVerdict {
311    /// Nothing private was found, or the destination is not public.
312    Pass,
313    /// A private term was found.
314    Refuse {
315        /// Where.
316        surface: Surface,
317    },
318    /// The check could not decide, and the write must not go ahead.
319    Unavailable {
320        /// Why.
321        reason: Unavailability,
322    },
323}
324
325/// How every refusal begins.
326pub(crate) const REFUSED_SENTENCE: &str = "public output carries a term of a private repository";
327
328/// How every unavailable check begins.
329pub(crate) const UNAVAILABLE_SENTENCE: &str = "the public boundary check is unavailable";
330
331impl BoundaryVerdict {
332    /// The neutral sentence a refusal or an unavailable check is reported in, or
333    /// `None` for a pass.
334    pub fn reason(&self) -> Option<String> {
335        match self {
336            BoundaryVerdict::Pass => None,
337            BoundaryVerdict::Refuse { surface } => {
338                Some(format!("{REFUSED_SENTENCE} in {}", surface.describe()))
339            }
340            BoundaryVerdict::Unavailable { reason } => {
341                Some(format!("{UNAVAILABLE_SENTENCE}: {}", reason.describe()))
342            }
343        }
344    }
345}
346
347/// The private detail behind a verdict: which term, of which identity, where.
348///
349/// Only [`check_public_output_with_evidence`] hands it out, into a vector the caller
350/// owns, and nothing in this crate ever prints it.
351#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
352#[serde(tag = "kind", rename_all = "kebab-case")]
353pub enum Evidence {
354    /// A term was found.
355    Term {
356        /// Where.
357        surface: Surface,
358        /// Which entry of that surface: an index into the input's list, or a path.
359        at: String,
360        /// The rule that matched.
361        rule: TermRule,
362        /// The identity the rule was derived from.
363        identity: String,
364    },
365    /// The check could not decide.
366    Unavailable {
367        /// Why, as the verdict says it.
368        reason: Unavailability,
369        /// The identity it could not read, when there was one.
370        #[serde(default, skip_serializing_if = "Option::is_none")]
371        identity: Option<String>,
372        /// What failed, in the words of whatever failed.
373        detail: String,
374    },
375}
376
377/// What one repository contributes to a check.
378#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
379#[serde(deny_unknown_fields)]
380pub struct RepositoryBoundary {
381    /// Its visibility as recorded, after a rule's override.
382    pub visibility: Visibility,
383    /// The terms it contributes: none when its effective visibility is public.
384    pub terms: Vec<TermRule>,
385}
386
387/// A repository's committed [`PRIVATE_TERMS_FILE`].
388#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
389#[serde(deny_unknown_fields)]
390pub struct PrivateTerms {
391    /// Always [`PRIVATE_TERMS_VERSION`].
392    pub schema_version: u32,
393    /// Terms beyond the derived ones, each matched as a whole word.
394    #[serde(default)]
395    pub terms: Vec<String>,
396    /// Changes to the derived and declared terms.
397    #[serde(default)]
398    pub exceptions: Vec<TermException>,
399}
400
401/// One declared exception: a term, and what to do to the rules that carry it.
402#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
403#[serde(deny_unknown_fields)]
404pub struct TermException {
405    /// The derived or declared term it applies to, compared without case.
406    pub term: String,
407    /// What it does.
408    pub action: ExceptionAction,
409}
410
411/// What an exception does to the rule it names.
412#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
413#[serde(rename_all = "kebab-case")]
414pub enum ExceptionAction {
415    /// Remove the rule.
416    Drop,
417    /// Match it only as a whole word.
418    WholeWord,
419    /// Match it only spelled exactly as the exception spells it.
420    CaseSensitive,
421    /// Match only the repository's fully qualified `owner/name`.
422    OwnerNameOnly,
423}
424
425/// One private repository, as terms are derived from it.
426#[derive(Debug, Clone, PartialEq, Eq)]
427pub struct TermSource {
428    /// The identity it is, which evidence names.
429    pub identity: String,
430    /// Its owner, for a hosted repository.
431    pub owner: Option<String>,
432    /// Its bare name.
433    pub name: String,
434    /// The package names its committed manifests declare.
435    pub packages: BTreeSet<String>,
436    /// Its committed term declaration, where it has one.
437    pub declaration: Option<PrivateTerms>,
438}
439
440impl TermSource {
441    /// Read a repository's committed manifests and declaration out of the commit
442    /// its checkout's `HEAD` names — never its worktree.
443    ///
444    /// `identity` is a normalized origin: `host/owner/name`, or a local path whose
445    /// last segment is taken as the name.
446    pub fn from_committed(checkout: &Path, identity: &str) -> Result<TermSource> {
447        manifests::committed_source(checkout, identity).map_err(|detail| Error::Invalid {
448            reason: format!("cannot read the committed term declarations: {detail}"),
449        })
450    }
451}
452
453/// What the registry knows is public, which narrows bare and package terms.
454#[derive(Debug, Clone, Default, PartialEq, Eq)]
455pub struct PublicNames {
456    /// The bare names of public repositories, in lower case.
457    pub repositories: BTreeSet<String>,
458    /// The logins of owners that own a public repository, in lower case.
459    pub owners: BTreeSet<String>,
460}
461
462/// The rules a set of private repositories yields, as the module documentation
463/// states them, with each repository's declared exceptions applied on top.
464///
465/// Refused — naming no term — when a declaration is one this build will not act on:
466/// a version it does not read, an empty term, two exceptions for one term, or an
467/// exception that names no term or names a repository's own `owner/name`.
468pub fn derive_terms(sources: &[TermSource], public: &PublicNames) -> Result<Vec<TermRule>> {
469    let mut rules = Vec::new();
470    for source in sources {
471        let derived = derive::derive(source, public).map_err(|detail| Error::Invalid {
472            reason: format!("a term declaration is refused: {detail}"),
473        })?;
474        rules.extend(derived);
475    }
476    rules.sort();
477    rules.dedup();
478    Ok(rules)
479}
480
481/// The one matcher, compiled over a set of rules.
482pub struct TermMatcher(matcher::Matcher);
483
484impl TermMatcher {
485    /// Compile `rules`, refusing a set too large for one automaton.
486    pub fn new(rules: Vec<TermRule>) -> Result<TermMatcher> {
487        matcher::Matcher::new(rules)
488            .map(TermMatcher)
489            .map_err(|unbuildable| Error::Invalid {
490                reason: format!("the terms could not be compiled: {}", unbuildable.0),
491            })
492    }
493
494    /// The rules it was compiled over.
495    pub fn rules(&self) -> &[TermRule] {
496        self.0.rules()
497    }
498
499    /// The index of every rule whose term `text` carries, ascending.
500    pub fn find(&self, text: &str) -> Vec<usize> {
501        self.0.find(text)
502    }
503}
504
505/// What `onevcs boundary inspect` reads: one repository.
506#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
507#[serde(deny_unknown_fields)]
508pub struct InspectRequest {
509    /// An identity key, a registered alias, an origin URL, or a path.
510    pub repository: String,
511}
512
513/// What `onevcs boundary inspect` answers: its visibility and nothing else.
514#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
515#[serde(deny_unknown_fields)]
516pub struct InspectAnswer {
517    /// Its visibility, refreshed from the host unless a rule overrides it.
518    pub visibility: Visibility,
519}
520
521/// The JSON schemas of the two boundary commands, generated from the types above.
522pub fn boundary_schema() -> serde_json::Value {
523    serde_json::json!({
524        "schema_version": BOUNDARY_SCHEMA_VERSION,
525        "inspect": {
526            "input": schemars::schema_for!(InspectRequest),
527            "output": schemars::schema_for!(InspectAnswer),
528        },
529        "check": {
530            "input": schemars::schema_for!(BoundaryInput),
531            "output": schemars::schema_for!(BoundaryVerdict),
532        },
533    })
534}
535
536/// What one repository contributes to a check: its visibility and its terms.
537///
538/// The library half of what `onevcs boundary inspect` answers with only the
539/// visibility of. Its visibility is refreshed from the host unless a rule overrides
540/// it, and recorded; its terms are derived from its committed tree.
541pub fn repository_boundary(repository: &str) -> Result<RepositoryBoundary> {
542    repository_boundary_with(&Providers::real(), repository)
543}
544
545/// [`repository_boundary`] through supplied implementations.
546pub(crate) fn repository_boundary_with(
547    providers: &Providers<'_>,
548    repository: &str,
549) -> Result<RepositoryBoundary> {
550    let visibility = visibility::refresh_named(providers.hosting, repository)?;
551    if visibility.effective() == Visibility::Public {
552        return Ok(RepositoryBoundary {
553            visibility,
554            terms: Vec::new(),
555        });
556    }
557    let registry = crate::store::load()?;
558    let resolution = crate::store::resolve(&registry, repository)?;
559    let derived = scope::derive(&TermScope::Identities(vec![resolution.key.clone()]));
560    match derived {
561        Ok(derived) => Ok(RepositoryBoundary {
562            visibility,
563            terms: derived.rules,
564        }),
565        Err(failed) => Err(Error::Invalid {
566            reason: format!(
567                "the terms of {repository:?} could not be derived: {}",
568                failed.reason.describe()
569            ),
570        }),
571    }
572}
573
574/// The visibility of one repository, refreshed and recorded: what `onevcs boundary
575/// inspect` answers.
576pub fn inspect_repository(request: &InspectRequest) -> Result<InspectAnswer> {
577    inspect_repository_with(&Providers::real(), request)
578}
579
580/// [`inspect_repository`] through supplied implementations.
581pub(crate) fn inspect_repository_with(
582    providers: &Providers<'_>,
583    request: &InspectRequest,
584) -> Result<InspectAnswer> {
585    Ok(InspectAnswer {
586        visibility: visibility::refresh_named(providers.hosting, &request.repository)?,
587    })
588}
589
590/// Decide whether `input` may be written to its destination.
591///
592/// A destination whose visibility is not `public` passes without a term being
593/// derived. Otherwise the terms of the private repositories `input.scope` selects
594/// are derived and matched against every entry, and the first hit refuses. Anything
595/// the check could not read is [`BoundaryVerdict::Unavailable`] — never a pass.
596pub fn check_public_output(input: BoundaryInput) -> Result<BoundaryVerdict> {
597    check_public_output_with_evidence(input, &mut Vec::new())
598}
599
600/// [`check_public_output`], pushing the private detail of its verdict onto
601/// `evidence`, which only the caller holds.
602pub fn check_public_output_with_evidence(
603    input: BoundaryInput,
604    evidence: &mut Vec<Evidence>,
605) -> Result<BoundaryVerdict> {
606    if input.destination.effective() != Visibility::Public {
607        return Ok(BoundaryVerdict::Pass);
608    }
609    let derived = match scope::derive(&input.scope) {
610        Ok(derived) => derived,
611        Err(failed) => return Ok(failed.verdict(evidence)),
612    };
613    let matcher = match derived.matcher() {
614        Ok(matcher) => matcher,
615        Err(failed) => return Ok(failed.verdict(evidence)),
616    };
617    let mut verdict = BoundaryVerdict::Pass;
618    for (surface, entries) in [
619        (Surface::Text, &input.text),
620        (Surface::Path, &input.paths),
621        (Surface::Metadata, &input.metadata),
622    ] {
623        for (index, entry) in entries.iter().enumerate() {
624            for rule in matcher.find(entry) {
625                if verdict == BoundaryVerdict::Pass {
626                    verdict = BoundaryVerdict::Refuse { surface };
627                }
628                evidence.push(derived.evidence(surface, index.to_string(), rule));
629            }
630        }
631    }
632    Ok(verdict)
633}