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