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(®istry, 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}