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