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;
8
9use serde::{Deserialize, Serialize};
10
11use onevcs::{ChangeId, ChangeRequest, Check, Error, Identity, MergeOutcome, Recoverable, Result};
12use onevcs::{Session, SessionRequest, SessionToken};
13
14use crate::events;
15use crate::store::Checked;
16
17/// The version of the state document this build writes and reads.
18///
19/// A file-backed state outlives the process that wrote it and is read by the next
20/// one, which makes it a stored contract like `onevcs`'s own registry document —
21/// and like that document, a version this build does not read is refused by name
22/// rather than guessed at. `1` is the shape the goldens in `tests/golden/` hold,
23/// and those goldens are compared byte for byte, so a field that changes shape
24/// cannot reach a consumer without the diff saying so.
25pub const STATE_VERSION: u32 = 1;
26
27/// Everything the repository side of a run knows about itself.
28///
29/// Every field is public and serializable, so a journey both seeds a scenario and
30/// asserts on what a run left behind. Everything but the version is omitted when
31/// it holds nothing, so a hand-written document names only the part of a scenario
32/// that matters — and a document written by a build that knew fewer fields still
33/// reads here.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35#[serde(default)]
36pub struct VcsState {
37    /// The schema version this state was written at. A document that names none is
38    /// this version: it is the only one there has ever been.
39    // llmlint: ignore[boundary_inputs_validated] deciding what to do with a version this
40    // build does not read is the whole of the check — and it is in `Checked::check` below,
41    // where a document is read, rather than here where serde only proves the shape.
42    pub version: u32,
43    /// The repository identities this provider can resolve. A
44    /// [`SessionRequest::repo`] naming none of them is refused, the way an
45    /// unregistered repository is.
46    #[serde(skip_serializing_if = "Vec::is_empty")]
47    pub identities: Vec<Identity>,
48    /// Every session opened or seeded, in the order they were opened.
49    #[serde(skip_serializing_if = "Vec::is_empty")]
50    pub sessions: Vec<Session>,
51    /// Which identity each session belongs to.
52    ///
53    /// Beyond the sketch this crate was specified from, and unavoidable: a
54    /// [`Session`] carries no identity, and a [`Recoverable`] must name one — so
55    /// preserving a session's branch could not answer the question `recoverable`
56    /// asks without this. `open_session` records it; nothing else writes it.
57    // llmlint: ignore[invalid_states_unrepresentable] an identity key is a `String`
58    // everywhere the crate this mirrors spells one — `Recoverable.identity`,
59    // `Identity.origin`, the registry document's own map key — and a newtype here would
60    // make a seeded state disagree with the types it is made of. Every value written to
61    // this map came out of `identity_of`, so it names an identity this provider holds.
62    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
63    pub session_identities: BTreeMap<SessionToken, String>,
64    /// Preserved work, newest last, as `recoverable` reports it.
65    ///
66    /// [`Recoverable`] rather than `PreservedBranch` — it *contains* the preserved
67    /// branch and adds the identity, the checkout, why the workstream stopped, and
68    /// the command that lands it, none of which are derivable from the branch
69    /// alone. One list rather than two that could disagree.
70    #[serde(skip_serializing_if = "Vec::is_empty")]
71    pub preserved: Vec<Recoverable>,
72}
73
74/// A repository side that knows nothing, at the version this build writes.
75impl Default for VcsState {
76    fn default() -> Self {
77        Self {
78            version: STATE_VERSION,
79            identities: Vec::new(),
80            sessions: Vec::new(),
81            session_identities: BTreeMap::new(),
82            preserved: Vec::new(),
83        }
84    }
85}
86
87/// Everything the remote-host side of a run knows about itself.
88///
89/// Omitted-when-empty and versioned for the same reasons [`VcsState`] is.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91#[serde(default)]
92pub struct HostState {
93    /// The schema version this state was written at. A document that names none is
94    /// this version.
95    // llmlint: ignore[boundary_inputs_validated] as on `VcsState::version`: the decision
96    // about an unreadable version is made in `Checked::check`, where the document is read.
97    pub version: u32,
98    /// Who the host says is calling. Empty is refused, exactly as a `gh` that
99    /// reports no authenticated user is.
100    // llmlint: ignore[invalid_states_unrepresentable] the interface this satisfies is
101    // `authenticated_user() -> Result<String>`, so the login is a `String` by contract and
102    // the one unusable value — a host that names nobody — is refused where it is read
103    // rather than made unrepresentable in a state a journey writes by hand.
104    pub authenticated_user: String,
105    /// Every change request that has been opened or seeded.
106    #[serde(skip_serializing_if = "Vec::is_empty")]
107    pub changes: Vec<ChangeRequest>,
108    /// The head branch each change request was opened from.
109    ///
110    /// Beyond the sketch, and unavoidable: [`ChangeRequest`] records only the base
111    /// it targets, and `find_changes` matches on the head as well.
112    // llmlint: ignore[invalid_states_unrepresentable] the matching `ChangeSpec.head` and
113    // `ChangeRequest.base` are `String` in the contract this mirrors, and a validated ref
114    // type here would disagree with them. Every value written to this map went through
115    // `addressable` in `open_change` first, which is the same refusal the real
116    // implementation makes at the same point.
117    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
118    pub heads: BTreeMap<ChangeId, String>,
119    /// The checks the host reports on each change request. A change with no entry
120    /// has no checks, which is what a repository with no CI reports.
121    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
122    pub checks: BTreeMap<ChangeId, Vec<Check>>,
123    /// The log the host hands over for a check, keyed by change request and then by
124    /// check name. Beyond the sketch: `check_log` is one of the six methods, and
125    /// without this the only log a journey could asssert on is a synthesized one.
126    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
127    pub check_logs: BTreeMap<ChangeId, BTreeMap<String, String>>,
128    /// What merging each change request did.
129    ///
130    /// Both a script and a record: an entry seeded here is what `merge` answers,
131    /// whatever the policy asks for — which is how a journey expresses a host that
132    /// queues or refuses — and a merge the policy decided is written back here.
133    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
134    pub merges: BTreeMap<ChangeId, MergeOutcome>,
135}
136
137/// Who a host with nothing seeded says is calling.
138///
139/// A host that answers nobody is refused by the real implementation, so a default
140/// state that answered nobody would be a provider that cannot run a publication
141/// until it is configured.
142pub const DEFAULT_AUTHENTICATED_USER: &str = "onevcs-testing";
143
144impl Default for HostState {
145    fn default() -> Self {
146        Self {
147            version: STATE_VERSION,
148            authenticated_user: DEFAULT_AUTHENTICATED_USER.to_owned(),
149            changes: Vec::new(),
150            heads: BTreeMap::new(),
151            checks: BTreeMap::new(),
152            check_logs: BTreeMap::new(),
153            merges: BTreeMap::new(),
154        }
155    }
156}
157
158/// The identity a session request names, or the reason none of them is it.
159///
160/// Three ways to name one, mirroring what the registry accepts: the identity key
161/// itself, the `owner/name` tail of it, or the bare repository name.
162pub(crate) fn identity_of<'a>(state: &'a VcsState, origin_or_path: &str) -> Option<&'a Identity> {
163    let wanted = origin_or_path.trim_end_matches('/');
164    state
165        .identities
166        .iter()
167        .find(|identity| identity.origin == wanted)
168        .or_else(|| {
169            state.identities.iter().find(|identity| {
170                identity
171                    .origin
172                    .rsplit('/')
173                    .next()
174                    .is_some_and(|name| name == wanted)
175                    || identity.origin.ends_with(&format!("/{wanted}"))
176            })
177        })
178}
179
180/// The known identities, as a refusal names them.
181pub(crate) fn known(state: &VcsState) -> String {
182    if state.identities.is_empty() {
183        return "this provider was seeded with no identities".to_owned();
184    }
185    let names: Vec<&str> = state
186        .identities
187        .iter()
188        .map(|identity| identity.origin.as_str())
189        .collect();
190    format!("it knows {}", names.join(", "))
191}
192
193/// The session a token names.
194pub(crate) fn session_of<'a>(state: &'a VcsState, token: &SessionToken) -> Option<&'a Session> {
195    state
196        .sessions
197        .iter()
198        .find(|session| session.token == *token)
199}
200
201/// The branch a request asks for, or the one that is derived from the token.
202pub(crate) fn requested_branch(req: &SessionRequest, token: &SessionToken) -> Result<String> {
203    let name = req
204        .branch
205        .clone()
206        .unwrap_or_else(|| format!("onevcs/{}", token.0));
207    named_branch(&name, "the branch")?;
208    Ok(name)
209}
210
211/// A branch name, refused here if git would refuse it.
212///
213/// The real implementation asks `git check-ref-format`, which is the parser that
214/// decides; a provider with no git carries `git-check-ref-format(1)`'s rules
215/// instead. That is a restatement, so it is gated rather than trusted:
216/// `refs.rs` in the suite runs both this and git itself over a table of names
217/// and holds them to each other, because a copy of somebody else's grammar with
218/// no gate is a copy that drifts.
219///
220/// One deliberate difference, and the gate knows about it: a leading `-` is
221/// refused here even though git accepts it as a ref, because such a name reaches
222/// a command line as an option rather than as the branch it spells.
223pub(crate) fn named_branch(value: &str, what: &str) -> Result<()> {
224    // Rule 1 is per slash-separated component; the rest are about the whole name.
225    let components_usable = !value.is_empty()
226        && value.split('/').all(|component| {
227            !component.is_empty() && !component.starts_with('.') && !component.ends_with(".lock")
228        });
229    let usable = components_usable
230        && !value.starts_with('-')
231        && !value.contains("..")
232        && !value.contains("@{")
233        && !value.ends_with('.')
234        && !value.ends_with('/')
235        && !value.chars().any(|c| {
236            c.is_whitespace() || c.is_ascii_control() || c == '\u{7f}' || "~^:?*[\\".contains(c)
237        });
238    if !usable {
239        return Err(Error::Invalid {
240            reason: format!("{what} {value:?} is a name git would not accept"),
241        });
242    }
243    Ok(())
244}
245
246/// A seeded repository side is refused if it holds a session nothing could act on.
247impl Checked for VcsState {
248    fn check(&self) -> Result<()> {
249        readable_version(self.version)?;
250        for session in &self.sessions {
251            // The token names a file under the state root, and a branch goes on to
252            // spell a ref; both arrive from whoever wrote the document.
253            if !events::is_safe_name(&session.token.0) {
254                return Err(Error::Invalid {
255                    reason: format!("{:?} is not a session token", session.token.0),
256                });
257            }
258            named_branch(&session.branch, "the branch")?;
259            named_branch(&session.base, "the base")?;
260        }
261        for row in &self.preserved {
262            named_branch(&row.branch.branch, "the preserved branch")?;
263            named_branch(&row.branch.base, "the preserved branch's base")?;
264        }
265        Ok(())
266    }
267}
268
269/// A seeded host side is refused if it holds a change nothing could address.
270impl Checked for HostState {
271    fn check(&self) -> Result<()> {
272        readable_version(self.version)?;
273        for change in &self.changes {
274            named_branch(&change.base, "the base of a seeded change request")?;
275            if change.id.0.is_empty() {
276                return Err(Error::Invalid {
277                    reason: "a seeded change request carries no identifier".to_owned(),
278                });
279            }
280            // The commit a change request's checks are reported against is the whole
281            // evidence that a change reached anything, and the real implementation
282            // refuses a host answer that names none rather than passing a blank one
283            // through. A seeded one is refused for the same reason.
284            if change.head_sha.0.trim().is_empty() {
285                return Err(Error::Invalid {
286                    reason: format!(
287                        "the seeded change request {:?} names no commit its checks are \
288                         reported against",
289                        change.id.0
290                    ),
291                });
292            }
293        }
294        for head in self.heads.values() {
295            named_branch(head, "the head of a seeded change request")?;
296        }
297        Ok(())
298    }
299}
300
301/// Refuse a document written at a version this build does not read.
302///
303/// Named rather than guessed at: a state whose shape is a later build's reads one
304/// way here and another way where that version is understood, and for a seeded
305/// scenario those two readings are two different tests.
306fn readable_version(declared: u32) -> Result<()> {
307    if declared != STATE_VERSION {
308        return Err(Error::Invalid {
309            reason: format!(
310                "the document declares version {declared}; this build reads version \
311                 {STATE_VERSION}"
312            ),
313        });
314    }
315    Ok(())
316}