Skip to main content

onevcs_testing/
state.rs

1//! What a provider knows, in a shape a journey can write down and read back.
2//!
3//! One state type per interface, shared by both flavours of it — the in-memory
4//! provider and the file-backed one differ in where the state lives and in nothing
5//! else, so a scenario seeded for one is the same scenario for the other.
6
7use std::collections::{BTreeMap, BTreeSet};
8
9use serde::{Deserialize, Serialize};
10
11use onevcs::{
12    ChangeId, ChangeRequest, Check, CheckSource, Error, Identity, MergeOutcome, Recoverable, Result,
13};
14use onevcs::{MergePolicy, Publication, Session, SessionRequest, SessionToken};
15
16use crate::events;
17use crate::store::Checked;
18
19/// The version of the state document this build writes and reads.
20///
21/// A file-backed state outlives the process that wrote it and is read by the next
22/// one, which makes it a stored contract like `onevcs`'s own registry document —
23/// and like that document, a version this build does not read is refused by name
24/// rather than guessed at. `2` is the shape the goldens in `tests/golden/` hold,
25/// and those goldens are compared byte for byte, so a field that changes shape
26/// cannot reach a consumer without the diff saying so.
27///
28/// `2` is what both sides learned when publishing and closing a session came
29/// through the interface: [`VcsState::policy`], [`VcsState::closed_sessions`],
30/// [`VcsState::publications`], and [`HostState::titles`]. A document at version `1`
31/// is refused by name rather than read: it describes a provider that could not
32/// publish, and every
33/// session in it would read back as open — which for a journey asserting on a
34/// session it had closed is a wrong answer rather than a missing one.
35pub const STATE_VERSION: u32 = 2;
36
37/// Everything the repository side of a run knows about itself.
38///
39/// Every field is public and serializable, so a journey both seeds a scenario and
40/// asserts on what a run left behind. Everything but the version is omitted when
41/// it holds nothing, so a hand-written document names only the part of a scenario
42/// that matters — and a document written by a build that knew fewer fields still
43/// reads here.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45#[serde(default)]
46pub struct VcsState {
47    /// The schema version this state was written at. A document that names none is
48    /// the one this build writes.
49    // llmlint: ignore[boundary_inputs_validated] deciding what to do with a version this
50    // build does not read is the whole of the check — and it is in `Checked::check` below,
51    // where a document is read, rather than here where serde only proves the shape.
52    pub version: u32,
53    /// The repository identities this provider can resolve. A
54    /// [`SessionRequest::repo`] naming none of them is refused, the way an
55    /// unregistered repository is.
56    #[serde(skip_serializing_if = "Vec::is_empty")]
57    pub identities: Vec<Identity>,
58    /// Every session opened or seeded, in the order they were opened.
59    #[serde(skip_serializing_if = "Vec::is_empty")]
60    pub sessions: Vec<Session>,
61    /// Which identity each session belongs to.
62    ///
63    /// Beyond the sketch this crate was specified from, and unavoidable: a
64    /// [`Session`] carries no identity, and a [`Recoverable`] must name one — so
65    /// preserving a session's branch could not answer the question `recoverable`
66    /// asks without this. `open_session` records it; nothing else writes it.
67    // llmlint: ignore[invalid_states_unrepresentable] an identity key is a `String`
68    // everywhere the crate this mirrors spells one — `Recoverable.identity`,
69    // `Identity.origin`, the registry document's own map key — and a newtype here would
70    // make a seeded state disagree with the types it is made of. Every value written to
71    // this map came out of `identity_of`, so it names an identity this provider holds.
72    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
73    pub session_identities: BTreeMap<SessionToken, String>,
74    /// Preserved work, newest last, as `recoverable` reports it.
75    ///
76    /// [`Recoverable`] rather than `PreservedBranch` — it *contains* the preserved
77    /// branch and adds the identity, the checkout, why the workstream stopped, and
78    /// the command that lands it, none of which are derivable from the branch
79    /// alone. One list rather than two that could disagree.
80    #[serde(skip_serializing_if = "Vec::is_empty")]
81    pub preserved: Vec<Recoverable>,
82    /// The sessions that have been closed. Every other session here is open.
83    ///
84    /// One way to say closed rather than two, so a scenario written by hand names
85    /// only the sessions whose lifecycle is not the one they were opened in.
86    // llmlint: ignore[invalid_states_unrepresentable] keyed by session token, exactly as
87    // `session_identities` above is and for the same reason: this document is a scenario
88    // somebody writes by hand, and a session is named once under `sessions` with the rest
89    // of the state keyed to it rather than nested inside a shape that could hold only one
90    // arrangement. A token here that names no opened session is refused in
91    // `Checked::check`, where the document is read — the same trust boundary every other
92    // cross-reference in it is checked at.
93    #[serde(skip_serializing_if = "BTreeSet::is_empty")]
94    pub closed_sessions: BTreeSet<SessionToken>,
95    /// The policy this provider publishes under.
96    ///
97    /// The answer a rules file gives the real implementation, which a provider has
98    /// none of — so a journey states it, and unset is the policy the contract's own
99    /// `default:` names ([`DEFAULT_PUBLICATION`](crate::DEFAULT_PUBLICATION)). A
100    /// per-run policy narrows it through [`MergePolicy::narrow`], which is the
101    /// rules system's rule rather than a restatement of it here.
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub policy: Option<MergePolicy>,
104    /// Every publication this provider performed, in the order it performed them.
105    ///
106    /// Both a record a journey asserts on and the answer to "has this session been
107    /// published already": a second publication of a session that landed has
108    /// nothing the base does not already carry, which is what the real
109    /// implementation reports for the same reason.
110    // llmlint: ignore[invalid_states_unrepresentable] this holds `onevcs::Publication`
111    // verbatim — the value `Vcs::publish` handed back, carrying its own session and branch
112    // — so a journey asserts on exactly what a caller would receive. A shape that made
113    // "this publication is of some other session's branch" unrepresentable could not hold
114    // that type, and would be a second spelling of the answer the crate next door already
115    // has. The cross-reference is checked in `Checked::check` instead, where the document
116    // is read.
117    #[serde(skip_serializing_if = "Vec::is_empty")]
118    pub publications: Vec<Publication>,
119}
120
121/// A repository side that knows nothing, at the version this build writes.
122impl Default for VcsState {
123    fn default() -> Self {
124        Self {
125            version: STATE_VERSION,
126            identities: Vec::new(),
127            sessions: Vec::new(),
128            session_identities: BTreeMap::new(),
129            preserved: Vec::new(),
130            closed_sessions: BTreeSet::new(),
131            policy: None,
132            publications: Vec::new(),
133        }
134    }
135}
136
137/// Everything the remote-host side of a run knows about itself.
138///
139/// Omitted-when-empty and versioned for the same reasons [`VcsState`] is.
140#[derive(Debug, Clone, Serialize, Deserialize)]
141#[serde(default)]
142pub struct HostState {
143    /// The schema version this state was written at. A document that names none is
144    /// the one this build writes.
145    // llmlint: ignore[boundary_inputs_validated] as on `VcsState::version`: the decision
146    // about an unreadable version is made in `Checked::check`, where the document is read.
147    pub version: u32,
148    /// Who the host says is calling. Empty is refused, exactly as a `gh` that
149    /// reports no authenticated user is.
150    // llmlint: ignore[invalid_states_unrepresentable] the interface this satisfies is
151    // `authenticated_user() -> Result<String>`, so the login is a `String` by contract and
152    // the one unusable value — a host that names nobody — is refused where it is read
153    // rather than made unrepresentable in a state a journey writes by hand.
154    pub authenticated_user: String,
155    /// Every change request that has been opened or seeded.
156    #[serde(skip_serializing_if = "Vec::is_empty")]
157    pub changes: Vec<ChangeRequest>,
158    /// The head branch each change request was opened from.
159    ///
160    /// Beyond the sketch, and unavoidable: [`ChangeRequest`] records only the base
161    /// it targets, and `find_changes` matches on the head as well.
162    // llmlint: ignore[invalid_states_unrepresentable] the matching `ChangeSpec.head` and
163    // `ChangeRequest.base` are `String` in the contract this mirrors, and a validated ref
164    // type here would disagree with them. Every value written to this map went through
165    // `addressable` in `open_change` first, which is the same refusal the real
166    // implementation makes at the same point.
167    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
168    pub heads: BTreeMap<ChangeId, String>,
169    /// The title each change request was opened under.
170    ///
171    /// Beyond the sketch, and for the same reason [`heads`](HostState::heads) is:
172    /// [`ChangeRequest`] records neither, and the title is what a publication's
173    /// commit subject becomes — so a journey asserting that the subject it asked
174    /// for is the one the host was given has nowhere else to read it.
175    // llmlint: ignore[invalid_states_unrepresentable] this records the `ChangeSpec.title`
176    // the contract fixes as a `String`, so a validated type here would disagree with the
177    // one it mirrors. `Subject` is not that type: it is `onevcs`'s rule for a *commit
178    // subject*, 72 characters, and a host's own limit is its own — spelling it here would
179    // refuse a seeded title a real host accepts, which is drift in the direction that
180    // looks like rigour. What the host itself refuses is a title that names nothing, and
181    // that is refused below and in `open_change`, at the boundary the value arrives at.
182    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
183    pub titles: BTreeMap<ChangeId, String>,
184    /// The checks the host reports on each change request. A change with no entry
185    /// has no checks, which is what a repository with no CI reports.
186    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
187    pub checks: BTreeMap<ChangeId, Vec<Check>>,
188    /// The log the host hands over for a check, keyed by change request and then by
189    /// check name. Beyond the sketch: `check_log` is one of the six methods, and
190    /// without this the only log a journey could asssert on is a synthesized one.
191    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
192    pub check_logs: BTreeMap<ChangeId, BTreeMap<String, String>>,
193    /// Which sources this host answers about its checks from, which is what the
194    /// real implementation reports alongside them.
195    ///
196    /// Unset is a credential allowed to read everything: the whole rollup, exactly
197    /// what a host with nothing to hide reports. A journey states a narrower set to
198    /// be the credential the real one meets in CI — a fine-grained token, which
199    /// cannot read check runs at all and sees GitHub Actions and nothing else — and
200    /// states an *empty* one to be a credential that can read no source at all,
201    /// which is a refusal rather than "no checks". Unset and empty are therefore
202    /// different scenarios, which is why this is an `Option` rather than a set whose
203    /// emptiness means "not stated".
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub check_sources: Option<BTreeSet<CheckSource>>,
206    /// What merging each change request did.
207    ///
208    /// Both a script and a record: an entry seeded here is what `merge` answers,
209    /// whatever the policy asks for — which is how a journey expresses a host that
210    /// queues or refuses — and a merge the policy decided is written back here.
211    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
212    pub merges: BTreeMap<ChangeId, MergeOutcome>,
213}
214
215/// Who a host with nothing seeded says is calling.
216///
217/// A host that answers nobody is refused by the real implementation, so a default
218/// state that answered nobody would be a provider that cannot run a publication
219/// until it is configured.
220pub const DEFAULT_AUTHENTICATED_USER: &str = "onevcs-testing";
221
222impl Default for HostState {
223    fn default() -> Self {
224        Self {
225            version: STATE_VERSION,
226            authenticated_user: DEFAULT_AUTHENTICATED_USER.to_owned(),
227            changes: Vec::new(),
228            heads: BTreeMap::new(),
229            titles: BTreeMap::new(),
230            checks: BTreeMap::new(),
231            check_logs: BTreeMap::new(),
232            check_sources: None,
233            merges: BTreeMap::new(),
234        }
235    }
236}
237
238/// The identity a session request names, or the reason none of them is it.
239///
240/// Three ways to name one, mirroring what the registry accepts: the identity key
241/// itself, the `owner/name` tail of it, or the bare repository name.
242pub(crate) fn identity_of<'a>(state: &'a VcsState, origin_or_path: &str) -> Option<&'a Identity> {
243    let wanted = origin_or_path.trim_end_matches('/');
244    state
245        .identities
246        .iter()
247        .find(|identity| identity.origin == wanted)
248        .or_else(|| {
249            state.identities.iter().find(|identity| {
250                identity
251                    .origin
252                    .rsplit('/')
253                    .next()
254                    .is_some_and(|name| name == wanted)
255                    || identity.origin.ends_with(&format!("/{wanted}"))
256            })
257        })
258}
259
260/// The known identities, as a refusal names them.
261pub(crate) fn known(state: &VcsState) -> String {
262    if state.identities.is_empty() {
263        return "this provider was seeded with no identities".to_owned();
264    }
265    let names: Vec<&str> = state
266        .identities
267        .iter()
268        .map(|identity| identity.origin.as_str())
269        .collect();
270    format!("it knows {}", names.join(", "))
271}
272
273/// The identity a session belongs to, or the reason this provider cannot say.
274///
275/// `open_session` records it and nothing else writes it, so a session that arrived
276/// in a hand-written scenario without one is refused here rather than published
277/// against a repository nobody named.
278pub(crate) fn identity_for(state: &VcsState, token: &SessionToken) -> Result<String> {
279    state
280        .session_identities
281        .get(token)
282        .cloned()
283        .ok_or_else(|| Error::Invalid {
284            reason: format!(
285                "this provider has no record of session {:?}, so it cannot say which identity \
286                 its work belongs to",
287                token.0
288            ),
289        })
290}
291
292/// The session a token names.
293pub(crate) fn session_of<'a>(state: &'a VcsState, token: &SessionToken) -> Option<&'a Session> {
294    state
295        .sessions
296        .iter()
297        .find(|session| session.token == *token)
298}
299
300/// The branch a request asks for, or the one that is derived from the token.
301pub(crate) fn requested_branch(req: &SessionRequest, token: &SessionToken) -> Result<String> {
302    let name = req
303        .branch
304        .clone()
305        .unwrap_or_else(|| format!("onevcs/{}", token.0));
306    named_branch(&name, "the branch")?;
307    Ok(name)
308}
309
310/// A branch name, refused here if git would refuse it.
311///
312/// The real implementation asks `git check-ref-format`, which is the parser that
313/// decides; a provider with no git carries `git-check-ref-format(1)`'s rules
314/// instead. That is a restatement, so it is gated rather than trusted:
315/// `refs.rs` in the suite runs both this and git itself over a table of names
316/// and holds them to each other, because a copy of somebody else's grammar with
317/// no gate is a copy that drifts.
318///
319/// One deliberate difference, and the gate knows about it: a leading `-` is
320/// refused here even though git accepts it as a ref, because such a name reaches
321/// a command line as an option rather than as the branch it spells.
322pub(crate) fn named_branch(value: &str, what: &str) -> Result<()> {
323    // Rule 1 is per slash-separated component; the rest are about the whole name.
324    let components_usable = !value.is_empty()
325        && value.split('/').all(|component| {
326            !component.is_empty() && !component.starts_with('.') && !component.ends_with(".lock")
327        });
328    let usable = components_usable
329        && !value.starts_with('-')
330        && !value.contains("..")
331        && !value.contains("@{")
332        && !value.ends_with('.')
333        && !value.ends_with('/')
334        && !value.chars().any(|c| {
335            c.is_whitespace() || c.is_ascii_control() || c == '\u{7f}' || "~^:?*[\\".contains(c)
336        });
337    if !usable {
338        return Err(Error::Invalid {
339            reason: format!("{what} {value:?} is a name git would not accept"),
340        });
341    }
342    Ok(())
343}
344
345/// A seeded repository side is refused if it holds a session nothing could act on.
346impl Checked for VcsState {
347    fn check(&self) -> Result<()> {
348        readable_version(self.version)?;
349        for session in &self.sessions {
350            // The token names a file under the state root, and a branch goes on to
351            // spell a ref; both arrive from whoever wrote the document.
352            if !events::is_safe_name(&session.token.0) {
353                return Err(Error::Invalid {
354                    reason: format!("{:?} is not a session token", session.token.0),
355                });
356            }
357            named_branch(&session.branch, "the branch")?;
358            named_branch(&session.base, "the base")?;
359        }
360        for row in &self.preserved {
361            known_identity(self, &row.identity, "preserved work")?;
362            named_branch(&row.branch.branch, "the preserved branch")?;
363            named_branch(&row.branch.base, "the preserved branch's base")?;
364        }
365        // Both of these name a session, so both are checked twice over: the token
366        // has to be a plain name, because it goes on to spell the file its stream is
367        // written in, and it has to name a session this state actually holds. A
368        // document that closes or publishes a session nobody opened describes a run
369        // that could not have happened, and answering `recoverable` or `session`
370        // from it would be answering from a fiction rather than refusing one.
371        for token in &self.closed_sessions {
372            opened(self, token, "closed")?;
373        }
374        for (token, origin) in &self.session_identities {
375            opened(self, token, "given an identity")?;
376            // The value as well as the key: this is what `identity_for` answers with,
377            // and it goes on to spell the slug a change request is opened against and
378            // the label every one of that session's events carries. An identity this
379            // provider does not know is one it could not have opened the session for.
380            known_identity(self, origin, &format!("session {:?}", token.0))?;
381        }
382        for publication in &self.publications {
383            let session = opened(self, &publication.session, "published")?;
384            named_branch(&publication.branch, "the published branch")?;
385            // A publication of some other branch than the one the session is on is
386            // the same kind of fiction, and the harder one to spot afterwards: the
387            // branch is what a journey asserts the publication was of.
388            if publication.branch != session.branch {
389                return Err(Error::Invalid {
390                    reason: format!(
391                        "the publication of session {:?} names branch {:?}, but that session is \
392                         on {:?}",
393                        publication.session.0, publication.branch, session.branch
394                    ),
395                });
396            }
397        }
398        Ok(())
399    }
400}
401
402/// Refuse a record kept about a change request this state does not hold.
403fn opened_change(state: &HostState, id: &ChangeId, what: &str) -> Result<()> {
404    if state.changes.iter().any(|change| change.id == *id) {
405        return Ok(());
406    }
407    Err(Error::Invalid {
408        reason: format!(
409            "{what} is recorded for change request {:?}, but no change request by that \
410             identifier was opened",
411            id.0
412        ),
413    })
414}
415
416/// Refuse an identity key this provider was not seeded with.
417fn known_identity(state: &VcsState, origin: &str, what: &str) -> Result<()> {
418    if state
419        .identities
420        .iter()
421        .any(|identity| identity.origin == origin)
422    {
423        return Ok(());
424    }
425    Err(Error::Invalid {
426        reason: format!(
427            "{what} belongs to identity {origin:?}, which this provider does not know; {}",
428            known(state)
429        ),
430    })
431}
432
433/// A change request's title, refused when it names nothing.
434///
435/// The one thing a real host refuses about a title, and the only one this provider
436/// may: how long a title may be is the host's own rule rather than `onevcs`'s
437/// commit-subject rule, and a provider applying the stricter of the two would
438/// refuse what the host it stands in for accepts.
439pub(crate) fn titled(title: &str) -> Result<()> {
440    if title.trim().is_empty() {
441        return Err(Error::Invalid {
442            reason: "a change request's title is blank, so it names no change".to_owned(),
443        });
444    }
445    Ok(())
446}
447
448/// The session a token names, refused when this state does not hold one.
449fn opened<'a>(state: &'a VcsState, token: &SessionToken, what: &str) -> Result<&'a Session> {
450    if !events::is_safe_name(&token.0) {
451        return Err(Error::Invalid {
452            reason: format!("{:?} is not a session token", token.0),
453        });
454    }
455    session_of(state, token).ok_or_else(|| Error::Invalid {
456        reason: format!(
457            "session {:?} is {what} here, but no session by that token was opened",
458            token.0
459        ),
460    })
461}
462
463/// A seeded host side is refused if it holds a change nothing could address.
464impl Checked for HostState {
465    fn check(&self) -> Result<()> {
466        readable_version(self.version)?;
467        for change in &self.changes {
468            named_branch(&change.base, "the base of a seeded change request")?;
469            if change.id.0.is_empty() {
470                return Err(Error::Invalid {
471                    reason: "a seeded change request carries no identifier".to_owned(),
472                });
473            }
474            // The commit a change request's checks are reported against is the whole
475            // evidence that a change reached anything, and the real implementation
476            // refuses a host answer that names none rather than passing a blank one
477            // through. A seeded one is refused for the same reason.
478            if change.head_sha.0.trim().is_empty() {
479                return Err(Error::Invalid {
480                    reason: format!(
481                        "the seeded change request {:?} names no commit its checks are \
482                         reported against",
483                        change.id.0
484                    ),
485                });
486            }
487        }
488        // Both of these are recorded *about* a change request, by `open_change` and
489        // by nothing else, and both are read back by the id they are keyed under. An
490        // entry for a change nobody opened is one no call could ever reach, so it is
491        // refused rather than carried — unlike the checks, logs, and merge outcomes
492        // below it, which a journey deliberately seeds for a change it has not
493        // opened yet.
494        for (id, head) in &self.heads {
495            opened_change(self, id, "a head")?;
496            named_branch(head, "the head of a seeded change request")?;
497        }
498        for (id, title) in &self.titles {
499            opened_change(self, id, "a title")?;
500            // The real host refuses a title that names nothing, so a seeded one is
501            // refused for the same reason.
502            titled(title)?;
503        }
504        Ok(())
505    }
506}
507
508/// Refuse a document written at a version this build does not read.
509///
510/// Named rather than guessed at: a state whose shape is a later build's reads one
511/// way here and another way where that version is understood, and for a seeded
512/// scenario those two readings are two different tests.
513fn readable_version(declared: u32) -> Result<()> {
514    if declared != STATE_VERSION {
515        return Err(Error::Invalid {
516            reason: format!(
517                "the document declares version {declared}; this build reads version \
518                 {STATE_VERSION}"
519            ),
520        });
521    }
522    Ok(())
523}