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}