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 = 2;
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    /// The host did not answer whether the destination is public — it was rate
286    /// limited, failing, or out of reach — so nothing it last said was replaced and
287    /// the write waits for it to answer.
288    HostVisibility,
289}
290
291impl Unavailability {
292    /// How a refusal names it.
293    pub fn describe(self) -> &'static str {
294        match self {
295            Unavailability::Registry => "the registry could not be read",
296            Unavailability::Rules => "the rules file could not be read",
297            Unavailability::Unregistered => "a repository in its term scope is not registered",
298            Unavailability::Declarations => {
299                "a private repository's committed manifests or term declaration could not be read"
300            }
301            Unavailability::Matcher => "the terms could not be compiled",
302            Unavailability::History => "what is being published could not be read",
303            Unavailability::HostVisibility => {
304                "the host did not answer whether the destination is public"
305            }
306        }
307    }
308}
309
310/// What the check decided.
311///
312/// The public-facing half carries no term, no identity and no path: a refusal says
313/// *where* a term was found and nothing about which. The detail is
314/// [`Evidence`], which only a caller that supplied somewhere private to put it gets.
315#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
316#[serde(tag = "verdict", rename_all = "kebab-case")]
317pub enum BoundaryVerdict {
318    /// Nothing private was found, or the destination is not public.
319    Pass,
320    /// A private term was found.
321    Refuse {
322        /// Where.
323        surface: Surface,
324    },
325    /// The check could not decide, and the write must not go ahead.
326    Unavailable {
327        /// Why.
328        reason: Unavailability,
329    },
330}
331
332/// How every refusal begins.
333pub(crate) const REFUSED_SENTENCE: &str = "public output carries a term of a private repository";
334
335/// How every unavailable check begins.
336pub(crate) const UNAVAILABLE_SENTENCE: &str = "the public boundary check is unavailable";
337
338impl BoundaryVerdict {
339    /// The neutral sentence a refusal or an unavailable check is reported in, or
340    /// `None` for a pass.
341    pub fn reason(&self) -> Option<String> {
342        match self {
343            BoundaryVerdict::Pass => None,
344            BoundaryVerdict::Refuse { surface } => {
345                Some(format!("{REFUSED_SENTENCE} in {}", surface.describe()))
346            }
347            BoundaryVerdict::Unavailable { reason } => {
348                Some(format!("{UNAVAILABLE_SENTENCE}: {}", reason.describe()))
349            }
350        }
351    }
352}
353
354/// The private detail behind a verdict: which term, of which identity, where.
355///
356/// Only [`check_public_output_with_evidence`] hands it out, into a vector the caller
357/// owns, and nothing in this crate ever prints it.
358#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
359#[serde(tag = "kind", rename_all = "kebab-case")]
360pub enum Evidence {
361    /// A term was found.
362    Term {
363        /// Where.
364        surface: Surface,
365        /// Which entry of that surface: an index into the input's list, or a path.
366        at: String,
367        /// The rule that matched.
368        rule: TermRule,
369        /// The identity the rule was derived from.
370        identity: String,
371    },
372    /// The check could not decide.
373    Unavailable {
374        /// Why, as the verdict says it.
375        reason: Unavailability,
376        /// The identity it could not read, when there was one.
377        #[serde(default, skip_serializing_if = "Option::is_none")]
378        identity: Option<String>,
379        /// What failed, in the words of whatever failed.
380        detail: String,
381    },
382}
383
384/// What one repository contributes to a check.
385#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
386#[serde(deny_unknown_fields)]
387pub struct RepositoryBoundary {
388    /// Its visibility as recorded, after a rule's override.
389    pub visibility: Visibility,
390    /// The terms it contributes: none when its effective visibility is public.
391    pub terms: Vec<TermRule>,
392}
393
394/// A repository's committed [`PRIVATE_TERMS_FILE`].
395#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
396#[serde(deny_unknown_fields)]
397pub struct PrivateTerms {
398    /// Always [`PRIVATE_TERMS_VERSION`].
399    pub schema_version: u32,
400    /// Terms beyond the derived ones, each matched as a whole word.
401    #[serde(default)]
402    pub terms: Vec<String>,
403    /// Changes to the derived and declared terms.
404    #[serde(default)]
405    pub exceptions: Vec<TermException>,
406}
407
408/// One declared exception: a term, and what to do to the rules that carry it.
409#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
410#[serde(deny_unknown_fields)]
411pub struct TermException {
412    /// The derived or declared term it applies to, compared without case.
413    pub term: String,
414    /// What it does.
415    pub action: ExceptionAction,
416}
417
418/// What an exception does to the rule it names.
419#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
420#[serde(rename_all = "kebab-case")]
421pub enum ExceptionAction {
422    /// Remove the rule.
423    Drop,
424    /// Match it only as a whole word.
425    WholeWord,
426    /// Match it only spelled exactly as the exception spells it.
427    CaseSensitive,
428    /// Match only the repository's fully qualified `owner/name`.
429    OwnerNameOnly,
430}
431
432/// One private repository, as terms are derived from it.
433#[derive(Debug, Clone, PartialEq, Eq)]
434pub struct TermSource {
435    /// The identity it is, which evidence names.
436    pub identity: String,
437    /// Its owner, for a hosted repository.
438    pub owner: Option<String>,
439    /// Its bare name.
440    pub name: String,
441    /// The package names its committed manifests declare.
442    pub packages: BTreeSet<String>,
443    /// Its committed term declaration, where it has one.
444    pub declaration: Option<PrivateTerms>,
445}
446
447impl TermSource {
448    /// Read a repository's committed manifests and declaration out of the commit
449    /// its checkout's `HEAD` names — never its worktree.
450    ///
451    /// `identity` is a normalized origin: `host/owner/name`, or a local path whose
452    /// last segment is taken as the name.
453    pub fn from_committed(checkout: &Path, identity: &str) -> Result<TermSource> {
454        manifests::committed_source(checkout, identity).map_err(|detail| Error::Invalid {
455            reason: format!("cannot read the committed term declarations: {detail}"),
456        })
457    }
458}
459
460/// What the registry knows is public, which narrows bare and package terms.
461#[derive(Debug, Clone, Default, PartialEq, Eq)]
462pub struct PublicNames {
463    /// The bare names of public repositories, in lower case.
464    pub repositories: BTreeSet<String>,
465    /// The logins of owners that own a public repository, in lower case.
466    pub owners: BTreeSet<String>,
467}
468
469/// The rules a set of private repositories yields, as the module documentation
470/// states them, with each repository's declared exceptions applied on top.
471///
472/// Refused — naming no term — when a declaration is one this build will not act on:
473/// a version it does not read, an empty term, two exceptions for one term, or an
474/// exception that names no term or names a repository's own `owner/name`.
475pub fn derive_terms(sources: &[TermSource], public: &PublicNames) -> Result<Vec<TermRule>> {
476    let mut rules = Vec::new();
477    for source in sources {
478        let derived = derive::derive(source, public).map_err(|detail| Error::Invalid {
479            reason: format!("a term declaration is refused: {detail}"),
480        })?;
481        rules.extend(derived);
482    }
483    rules.sort();
484    rules.dedup();
485    Ok(rules)
486}
487
488/// The one matcher, compiled over a set of rules.
489pub struct TermMatcher(matcher::Matcher);
490
491impl TermMatcher {
492    /// Compile `rules`, refusing a set too large for one automaton.
493    pub fn new(rules: Vec<TermRule>) -> Result<TermMatcher> {
494        matcher::Matcher::new(rules)
495            .map(TermMatcher)
496            .map_err(|unbuildable| Error::Invalid {
497                reason: format!("the terms could not be compiled: {}", unbuildable.0),
498            })
499    }
500
501    /// The rules it was compiled over.
502    pub fn rules(&self) -> &[TermRule] {
503        self.0.rules()
504    }
505
506    /// The index of every rule whose term `text` carries, ascending.
507    pub fn find(&self, text: &str) -> Vec<usize> {
508        self.0.find(text)
509    }
510}
511
512/// What `onevcs boundary inspect` reads: one repository.
513#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
514#[serde(deny_unknown_fields)]
515pub struct InspectRequest {
516    /// An identity key, a registered alias, an origin URL, or a path.
517    pub repository: String,
518}
519
520/// What `onevcs boundary inspect` answers: its visibility and nothing else.
521#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
522#[serde(deny_unknown_fields)]
523pub struct InspectAnswer {
524    /// Its visibility, refreshed from the host unless a rule overrides it.
525    pub visibility: Visibility,
526}
527
528/// The JSON schemas of the two boundary commands, generated from the types above.
529pub fn boundary_schema() -> serde_json::Value {
530    serde_json::json!({
531        "schema_version": BOUNDARY_SCHEMA_VERSION,
532        "inspect": {
533            "input": schemars::schema_for!(InspectRequest),
534            "output": schemars::schema_for!(InspectAnswer),
535        },
536        "check": {
537            "input": schemars::schema_for!(BoundaryInput),
538            "output": schemars::schema_for!(BoundaryVerdict),
539        },
540    })
541}
542
543/// What one repository contributes to a check: its visibility and its terms.
544///
545/// The library half of what `onevcs boundary inspect` answers with only the
546/// visibility of. Its visibility is refreshed from the host unless a rule overrides
547/// it, and recorded; its terms are derived from its committed tree.
548pub fn repository_boundary(repository: &str) -> Result<RepositoryBoundary> {
549    repository_boundary_with(&Providers::real(), repository)
550}
551
552/// [`repository_boundary`] through supplied implementations.
553pub(crate) fn repository_boundary_with(
554    providers: &Providers<'_>,
555    repository: &str,
556) -> Result<RepositoryBoundary> {
557    let visibility = visibility::refresh_named(providers.hosting, repository)?;
558    if visibility.effective() == Visibility::Public {
559        return Ok(RepositoryBoundary {
560            visibility,
561            terms: Vec::new(),
562        });
563    }
564    let registry = crate::store::load()?;
565    let resolution = crate::store::resolve(&registry, repository)?;
566    let derived = scope::derive(&TermScope::Identities(vec![resolution.key.clone()]));
567    match derived {
568        Ok(derived) => Ok(RepositoryBoundary {
569            visibility,
570            terms: derived.rules,
571        }),
572        Err(failed) => Err(Error::Invalid {
573            reason: format!(
574                "the terms of {repository:?} could not be derived: {}",
575                failed.reason.describe()
576            ),
577        }),
578    }
579}
580
581/// The visibility of one repository, refreshed and recorded: what `onevcs boundary
582/// inspect` answers.
583pub fn inspect_repository(request: &InspectRequest) -> Result<InspectAnswer> {
584    inspect_repository_with(&Providers::real(), request)
585}
586
587/// [`inspect_repository`] through supplied implementations.
588pub(crate) fn inspect_repository_with(
589    providers: &Providers<'_>,
590    request: &InspectRequest,
591) -> Result<InspectAnswer> {
592    Ok(InspectAnswer {
593        visibility: visibility::refresh_named(providers.hosting, &request.repository)?,
594    })
595}
596
597/// Decide whether `input` may be written to its destination.
598///
599/// A destination whose visibility is not `public` passes without a term being
600/// derived. Otherwise the terms of the private repositories `input.scope` selects
601/// are derived and matched against every entry, and the first hit refuses. Anything
602/// the check could not read is [`BoundaryVerdict::Unavailable`] — never a pass.
603pub fn check_public_output(input: BoundaryInput) -> Result<BoundaryVerdict> {
604    check_public_output_with_evidence(input, &mut Vec::new())
605}
606
607/// [`check_public_output`], pushing the private detail of its verdict onto
608/// `evidence`, which only the caller holds.
609pub fn check_public_output_with_evidence(
610    input: BoundaryInput,
611    evidence: &mut Vec<Evidence>,
612) -> Result<BoundaryVerdict> {
613    if input.destination.effective() != Visibility::Public {
614        return Ok(BoundaryVerdict::Pass);
615    }
616    let derived = match scope::derive(&input.scope) {
617        Ok(derived) => derived,
618        Err(failed) => return Ok(failed.verdict(evidence)),
619    };
620    let matcher = match derived.matcher() {
621        Ok(matcher) => matcher,
622        Err(failed) => return Ok(failed.verdict(evidence)),
623    };
624    let mut verdict = BoundaryVerdict::Pass;
625    for (surface, entries) in [
626        (Surface::Text, &input.text),
627        (Surface::Path, &input.paths),
628        (Surface::Metadata, &input.metadata),
629    ] {
630        for (index, entry) in entries.iter().enumerate() {
631            for rule in matcher.find(entry) {
632                if verdict == BoundaryVerdict::Pass {
633                    verdict = BoundaryVerdict::Refuse { surface };
634                }
635                evidence.push(derived.evidence(surface, index.to_string(), rule));
636            }
637        }
638    }
639    Ok(verdict)
640}