Skip to main content

git_policy_cli/
service_protocol.rs

1//! What:
2//!  The messages between the wrapper and the resident commit service, one JSON object per line.
3//! Why:
4//!  A client sends a prepared change with a request identity and waits; the service answers with a landing, a queue
5//!  position, a request to prepare again, or a failure from a closed list. The line form needs no framing library and
6//!  every field is checked on read, so a damaged or hostile line is refused rather than guessed
7//!  (`doc/planning/cli-git-resident-service.md`, "Protocol and request identity").
8//!
9//! In TS you'd write (pseudocode):
10//! ```ts
11//! socket.write(JSON.stringify(encodeRequest(request)) + '\n'); const reply = decodeResponse(await readLine(socket));
12//! ```
13
14use serde_json::{Map, Value};
15use super::service_review_key::ReviewKey;
16use std::path::{Path, PathBuf};
17
18/// The protocol version this build speaks; a service and a client agree when their versions are equal.
19pub const PROTOCOL_VERSION: u32 = 5;
20
21/// The release of this build: the crate version, which the wrapper and the service program share because they are built
22///  and installed together. A service from another release is retired even when it speaks the same protocol, since the
23///  program its unit names, and the behavior behind the same messages, may differ.
24pub const RELEASE: &str = env!("CARGO_PKG_VERSION");
25
26/// The longest line either side accepts, in bytes.
27pub const MAX_LINE_BYTES: usize = 1 << 20;
28
29/// The most paths one request may change.
30pub const MAX_CHANGES: usize = 4096;
31
32/// Modes a change may give a file.
33pub const ALLOWED_MODES: [u32; 3] = [0o100_644, 0o100_755, 0o120_000];
34
35/// What:
36///  One path a commit changes: a file to set, or a file to delete.
37/// Why:
38///   The client has already written the file's content into the repository's object store, so the service names the
39///  object and never reads the user's worktree.
40///
41/// In TS you'd write (pseudocode):
42/// ```ts
43/// type Change = { path: string; mode: number; blob?: string }; // no blob means delete
44/// ```
45#[derive(Clone, Debug, Eq, PartialEq)]
46pub struct Change {
47    /// Repository-relative path with `/` separators.
48    pub path: String,
49    /// File mode, one of `ALLOWED_MODES`.
50    pub mode: u32,
51    /// Object name of the content, or nothing for a deletion.
52    pub blob: Option<String>,
53}
54
55/// What:
56///  What the landing of a merge, cherry-pick or revert conclusion removes from the worktree's Git directory once the
57///  commit is on the branch.
58/// Why:
59///   Native `git commit` removes `MERGE_HEAD` and its siblings when it concludes; the client built the commit with real
60///  Git in a scratch repository that holds the state it was given, so the entries real Git removed there are the ones the
61///  landing removes here, each only while it still holds the bytes copied at preparation.
62///
63/// In TS you'd write (pseudocode):
64/// ```ts
65/// type Cleanup = { gitDir: string; scratch: string; reftable: boolean };
66/// ```
67#[derive(Clone, Debug, Eq, PartialEq)]
68pub struct Cleanup {
69    /// The Git directory of the worktree the commit is made in.
70    pub git_dir: PathBuf,
71    /// The commit's scratch directory, which holds the conclusion copies and the scratch repository Git committed in.
72    pub scratch: PathBuf,
73    /// Whether the repository stores references in reftable tables, which hold three of the entries in the ref store.
74    pub reftable: bool,
75}
76
77/// What:
78///  A request to land one commit.
79/// Why:
80///   Every field the service needs to land it on the current branch without asking the client anything.
81///
82/// In TS you'd write (pseudocode):
83/// ```ts
84/// type CommitRequest = { id; repository; commonDir; reference; base; changes; message; author?; committer };
85/// ```
86#[derive(Clone, Debug, Eq, PartialEq)]
87pub struct CommitRequest {
88    /// Random identity of this invocation, kept across reconnects.
89    pub id: String,
90    /// Worktree root whose index is updated.
91    pub repository: PathBuf,
92    /// Common git directory shared by the worktrees.
93    pub common_dir: PathBuf,
94    /// The worktree's own Git directory (`.git` of the main worktree, `.git/worktrees/<id>` of a linked one): the key of the
95    ///  service instance that lands the commit, which refuses a request that names another worktree's.
96    pub git_dir: PathBuf,
97    /// Full name of the branch the commit goes on.
98    pub reference: String,
99    /// The commit the client prepared against.
100    pub base: String,
101    /// The paths to change.
102    pub changes: Vec<Change>,
103    /// The commit message, exact bytes.
104    pub message: String,
105    /// `Name <email> seconds zone`, or nothing to use the committer.
106    pub author: Option<String>,
107    /// `Name <email> seconds zone`.
108    pub committer: String,
109    /// Whether the commit replaces the branch tip: its parents are the base commit's parents, and it lands only while
110    ///  the branch tip is still the base.
111    pub amend: bool,
112    /// The tree of an amend, which the client wrote into the repository's object store; nothing for any other request,
113    ///  whose tree the service makes from the base and the changes.
114    pub tree: Option<String>,
115    /// A commit Git built in a scratch repository (an editor, a signature), already in the repository's object store. The
116    ///  service lands it as it is, only while the branch tip is still the base, and ignores the message, the identities
117    ///  and the tree of the request.
118    pub commit: Option<String>,
119    /// The state files a conclusion removes once the commit has landed; only with a commit built elsewhere.
120    pub cleanup: Option<Cleanup>,
121    /// The real index of the worktree, whose lock the landing holds from before the branch moves until the new index is in place.
122    pub index: PathBuf,
123    /// When the client finished capturing the worktree's bytes: nanoseconds of `CLOCK_MONOTONIC`, which one clock gives every
124    ///  process of the host, so two captures of one worktree compare whichever process made them; zero when unknown. The
125    ///  service keeps it with the commit it lands, and a retry uses it to tell which of two overlapping captures is the later.
126    pub captured: u64,
127}
128
129/// A commit the service landed and when its bytes were captured, as the service tells a client whose commit it sends back.
130#[derive(Clone, Debug, Eq, PartialEq)]
131pub struct LandedCapture {
132    /// The landed commit.
133    pub commit: String,
134    /// The `captured` stamp of the request that made it.
135    pub captured: u64,
136}
137
138/// Whether `name` is the all-zero object name, which stands for a branch that does not exist yet.
139pub fn is_zero_object(name: &str) -> bool {
140    return is_object_name(name) && name.bytes().all(|byte| { return byte == b'0'; });
141}
142
143impl CommitRequest {
144    /// Whether the request lands alone and only while the branch tip equals its base: an amend, or a commit built elsewhere.
145    pub fn lands_alone(&self) -> bool {
146        return self.amend || self.commit.is_some();
147    }
148}
149
150/// The closed list of reasons the service gives for not landing a request.
151#[derive(Clone, Copy, Debug, Eq, PartialEq)]
152pub enum FailureCode {
153    /// The repository has no room.
154    RepositoryFull,
155    /// The repository cannot be written.
156    RepositoryReadOnly,
157    /// Another program holds the repository's index.
158    IndexInUse,
159    /// The request is not one the service accepts.
160    Rejected,
161    /// The branch moved too often while the request waited.
162    BranchKeepsChanging,
163    /// The branch is no longer where the commit was prepared, and the commit cannot be made again on another tip.
164    BranchMoved,
165    /// Anything else the service could not resolve.
166    Unavailable,
167}
168
169impl FailureCode {
170    /// The word on the wire.
171    pub fn word(self) -> &'static str {
172        return match self {
173            Self::RepositoryFull => "repository-full",
174            Self::RepositoryReadOnly => "repository-read-only",
175            Self::IndexInUse => "index-in-use",
176            Self::Rejected => "rejected",
177            Self::BranchKeepsChanging => "branch-keeps-changing",
178            Self::BranchMoved => "branch-moved",
179            Self::Unavailable => "unavailable",
180        };
181    }
182
183    /// Every code, so a test reaches all of them.
184    pub const ALL: [Self; 7] = [
185        Self::RepositoryFull,
186        Self::RepositoryReadOnly,
187        Self::IndexInUse,
188        Self::Rejected,
189        Self::BranchKeepsChanging,
190        Self::BranchMoved,
191        Self::Unavailable,
192    ];
193
194    /// The code for a word, nothing for an unknown word.
195    pub fn from_word(word: &str) -> Option<Self> {
196        return Self::ALL.into_iter().find(|code| { return code.word() == word; });
197    }
198}
199
200/// What a client sends.
201#[derive(Clone, Debug, Eq, PartialEq)]
202pub enum ClientMessage {
203    /// Land this commit.
204    Commit(Box<CommitRequest>),
205    /// Finish the queue and exit, so the manager starts the current binary.
206    Retire,
207    /// The sender keeps this scratch directory of a commit in preparation for as long as the connection stays open; the
208    ///  service removes the directory when the connection closes, whatever closed it.
209    Scratch {
210        /// The worktree's own Git directory, which holds the directory and names the instance that removes it.
211        git_dir: PathBuf,
212        /// The scratch directory (`cli-git-service-<id>` in a Git directory of that repository).
213        path: PathBuf,
214    },
215    /// Work items were written for this repository; run what is pending.
216    Work {
217        /// The worktree's own Git directory, which holds the work directory.
218        git_dir: PathBuf,
219        /// The gateway credential for the standards review of the commits this message announces. It lives only in this
220        /// message and in the service's memory, never in a work item or on disk.
221        review_key: Option<ReviewKey>,
222    },
223}
224
225/// What the service sends.
226#[derive(Clone, Debug, Eq, PartialEq)]
227pub enum ServiceMessage {
228    /// First line of every connection: the service's protocol version and the release it was built from. Services of
229    ///  releases before the field existed send none, which reads as a release that is not this one.
230    Welcome { version: u32, release: Option<String> },
231    /// The request is queued behind others.
232    Position { number: usize, of: usize },
233    /// The commit is on the branch; `already` when an earlier request with the same identity landed it.
234    Landed { id: String, commit: String, already: bool },
235    /// A named path changed on the branch since the base; prepare again from `base`.
236    RetryPrepare { id: String, base: String, landed: Vec<LandedCapture> },
237    /// The request was not landed.
238    Failed { id: String, code: FailureCode },
239    /// The landing waits for another program that holds the repository's index: a line for the person's terminal.
240    Waiting { text: String },
241    /// Who held the index when the wait ran out, in words that fit "... is using this repository's index".
242    Holder { who: String },
243}
244
245fn field<'a>(value: &'a Value, name: &str) -> Result<&'a str, String> {
246    return value.get(name).and_then(Value::as_str).ok_or_else(|| { return format!("field {name} is missing or not text"); });
247}
248
249/// Whether `text` is a full object name in hexadecimal (40 or 64 digits).
250pub fn is_object_name(text: &str) -> bool {
251    return (text.len() == 40 || text.len() == 64) && text.bytes().all(|byte| { return byte.is_ascii_hexdigit(); });
252}
253
254/// Whether `path` is a plain repository-relative path: no leading slash, no empty, `.` or `..` part, no NUL or newline.
255pub fn is_plain_path(path: &str) -> bool {
256    return !path.is_empty()
257        && !path.starts_with('/')
258        && !path.contains(['\0', '\n'])
259        && path.split('/').all(|part| { return !part.is_empty() && part != "." && part != ".." && part != ".git"; });
260}
261
262/// The name prefix of a commit's scratch directory.
263pub const SCRATCH_PREFIX: &str = "cli-git-service-";
264
265/// What:
266///  Whether `path` is the scratch directory of a commit in the worktree whose own Git directory is `git_dir`.
267/// Why:
268///   The service removes whatever a client names when the client's connection closes, so the name must be one only the
269///  wrapper makes (the prefix and an identity of letters, digits and dashes) and the place must be directly inside the
270///  worktree's own Git directory, with no relative part that could lead elsewhere.
271///
272/// In TS you'd write (pseudocode):
273/// ```ts
274/// isScratchPath(path, gitDir)
275/// ```
276pub fn is_scratch_path(path: &Path, git_dir: &Path) -> bool {
277    let Some(name) = path.file_name().and_then(|name| { return name.to_str(); }) else { return false };
278    let identity: &str = name.strip_prefix(SCRATCH_PREFIX).unwrap_or("");
279    return git_dir.is_absolute()
280        && path.parent() == Some(git_dir)
281        && !identity.is_empty()
282        && identity.bytes().all(|byte| { return byte.is_ascii_alphanumeric() || byte == b'-'; })
283        && git_dir.components().all(|part| { return !matches!(part, std::path::Component::ParentDir | std::path::Component::CurDir); })
284        && !path.to_string_lossy().contains('\0');
285}
286
287/// One JSON object, written on one line from its entries.
288fn object(entries: Vec<(&str, Value)>) -> String {
289    let mut map: Map<String, Value> = Map::new();
290    for (key, value) in entries {
291        map.insert(key.to_string(), value);
292    }
293    return Value::Object(map).to_string();
294}
295
296/// What:
297///  The line that asks a service to retire, written in the protocol version that service speaks.
298/// Why:
299///   A service reads a line only in its own version, except that services built with this check read a retire at any
300///  version. A service of an earlier release (the ones that sent no release in their greeting) refuses a retire written in
301///  a newer version as unsupported and stays, so the line must carry the version the service announced.
302///
303/// In TS you'd write (pseudocode):
304/// ```ts
305/// JSON.stringify({ type: 'retire', version: announcedVersion })
306/// ```
307pub fn encode_retire(version: u32) -> String {
308    return object(vec![("type", Value::from("retire")), ("version", Value::from(version))]);
309}
310
311/// What:
312///  The line for a client message.
313/// Why:
314///   One object per line, no newline inside any field.
315///
316/// In TS you'd write (pseudocode):
317/// ```ts
318/// JSON.stringify(message)
319/// ```
320pub fn encode_client(message: &ClientMessage) -> String {
321    return match message {
322        ClientMessage::Retire => encode_retire(PROTOCOL_VERSION),
323        ClientMessage::Scratch { git_dir, path } => object(vec![
324            ("type", Value::from("scratch")),
325            ("version", Value::from(PROTOCOL_VERSION)),
326            ("gitDir", Value::from(git_dir.to_string_lossy().as_ref())),
327            ("path", Value::from(path.to_string_lossy().as_ref())),
328        ]),
329        ClientMessage::Work { git_dir, review_key } => {
330            let mut entries: Vec<(&str, Value)> = vec![
331                ("type", Value::from("work")),
332                ("version", Value::from(PROTOCOL_VERSION)),
333                ("gitDir", Value::from(git_dir.to_string_lossy().as_ref())),
334            ];
335            if let Some(key) = review_key {
336                entries.push(("reviewKey", Value::from(key.expose())));
337            }
338            object(entries)
339        }
340        ClientMessage::Commit(request) => object(vec![
341            ("type", Value::from("commit")),
342            ("version", Value::from(PROTOCOL_VERSION)),
343            ("id", Value::from(request.id.as_str())),
344            ("repository", Value::from(request.repository.to_string_lossy().as_ref())),
345            ("commonDir", Value::from(request.common_dir.to_string_lossy().as_ref())),
346            ("gitDir", Value::from(request.git_dir.to_string_lossy().as_ref())),
347            ("reference", Value::from(request.reference.as_str())),
348            ("base", Value::from(request.base.as_str())),
349            (
350                "changes",
351                Value::Array(
352                    request
353                        .changes
354                        .iter()
355                        .map(|change| {
356                            return Value::Object(Map::from_iter([
357                                (String::from("path"), Value::from(change.path.as_str())),
358                                (String::from("mode"), Value::from(change.mode)),
359                                (String::from("blob"), change.blob.as_deref().map_or(Value::Null, Value::from)),
360                            ]));
361                        })
362                        .collect(),
363                ),
364            ),
365            ("message", Value::from(request.message.as_str())),
366            ("author", request.author.as_deref().map_or(Value::Null, Value::from)),
367            ("committer", Value::from(request.committer.as_str())),
368            ("amend", Value::from(request.amend)),
369            ("tree", request.tree.as_deref().map_or(Value::Null, Value::from)),
370            ("commit", request.commit.as_deref().map_or(Value::Null, Value::from)),
371            ("index", Value::from(request.index.to_string_lossy().as_ref())),
372            ("captured", Value::from(request.captured)),
373            (
374                "cleanup",
375                request.cleanup.as_ref().map_or(Value::Null, |cleanup| {
376                    return Value::Object(Map::from_iter([
377                        (String::from("gitDir"), Value::from(cleanup.git_dir.to_string_lossy().as_ref())),
378                        (String::from("scratch"), Value::from(cleanup.scratch.to_string_lossy().as_ref())),
379                        (String::from("reftable"), Value::from(cleanup.reftable)),
380                    ]));
381                }),
382            ),
383        ]),
384    };
385}
386
387/// What:
388///  Reads and checks one client line.
389/// Why:
390///   The service trusts nothing from a socket: sizes, names, paths, modes and the object names are all checked.
391///
392/// In TS you'd write (pseudocode):
393/// ```ts
394/// decodeClient(line) // throws on anything that is not a well-formed message
395/// ```
396pub fn decode_client(line: &str) -> Result<ClientMessage, String> {
397    if line.len() > MAX_LINE_BYTES {
398        return Err(String::from("line is too long"));
399    }
400    let value: Value = serde_json::from_str(line).map_err(|error| { return error.to_string(); })?;
401    // A request to retire carries no data a version could change the meaning of, and it is the one message a client of
402    //  another protocol version must still be able to send, so that service can be replaced.
403    if field(&value, "type")? == "retire" {
404        return Ok(ClientMessage::Retire);
405    }
406    if value.get("version").and_then(Value::as_u64) != Some(u64::from(PROTOCOL_VERSION)) {
407        return Err(String::from("unsupported version"));
408    }
409    match field(&value, "type")? {
410        "retire" => return Ok(ClientMessage::Retire),
411        "scratch" => {
412            let git_dir: PathBuf = PathBuf::from(field(&value, "gitDir")?);
413            let path: PathBuf = PathBuf::from(field(&value, "path")?);
414            if !is_scratch_path(path.as_path(), git_dir.as_path()) {
415                return Err(String::from("scratch directory is not acceptable"));
416            }
417            return Ok(ClientMessage::Scratch { git_dir, path });
418        }
419        "work" => {
420            let git_dir: PathBuf = PathBuf::from(field(&value, "gitDir")?);
421            if !git_dir.is_absolute() || git_dir.to_string_lossy().contains('\0') {
422                return Err(String::from("work request is not acceptable"));
423            }
424            let review_key: Option<ReviewKey> = match value.get("reviewKey") {
425                None | Some(Value::Null) => None,
426                Some(Value::String(text)) => ReviewKey::new(text.as_str()),
427                Some(_) => return Err(String::from("work request is not acceptable")),
428            };
429            return Ok(ClientMessage::Work { git_dir, review_key });
430        }
431        "commit" => {}
432        other => return Err(format!("unknown message type {other}")),
433    }
434    let mut changes: Vec<Change> = Vec::new();
435    for entry in value.get("changes").and_then(Value::as_array).ok_or("changes is missing")? {
436        let path: &str = field(entry, "path")?;
437        let mode: u32 = u32::try_from(entry.get("mode").and_then(Value::as_u64).ok_or("mode is missing")?)
438            .map_err(|_| { return String::from("mode is out of range"); })?;
439        let blob: Option<String> = entry.get("blob").and_then(Value::as_str).map(str::to_string);
440        if !is_plain_path(path) || !ALLOWED_MODES.contains(&mode) || blob.as_deref().is_some_and(|name| { return !is_object_name(name); }) {
441            return Err(format!("change for {path:?} is not acceptable"));
442        }
443        changes.push(Change { path: path.to_string(), mode, blob });
444    }
445    if changes.len() > MAX_CHANGES {
446        return Err(String::from("number of changes is not acceptable"));
447    }
448    let request = CommitRequest {
449        id: field(&value, "id")?.to_string(),
450        repository: PathBuf::from(field(&value, "repository")?),
451        common_dir: PathBuf::from(field(&value, "commonDir")?),
452        git_dir: PathBuf::from(field(&value, "gitDir")?),
453        reference: field(&value, "reference")?.to_string(),
454        base: field(&value, "base")?.to_string(),
455        changes,
456        message: field(&value, "message")?.to_string(),
457        author: value.get("author").and_then(Value::as_str).map(str::to_string),
458        committer: field(&value, "committer")?.to_string(),
459        amend: value.get("amend").and_then(Value::as_bool).unwrap_or(false),
460        tree: value.get("tree").and_then(Value::as_str).map(str::to_string),
461        commit: value.get("commit").and_then(Value::as_str).map(str::to_string),
462        index: PathBuf::from(field(&value, "index")?),
463        captured: value.get("captured").and_then(Value::as_u64).unwrap_or(0),
464        cleanup: match value.get("cleanup").filter(|entry| { return !entry.is_null(); }) {
465            None => None,
466            Some(entry) => Some(Cleanup {
467                git_dir: PathBuf::from(field(entry, "gitDir")?),
468                scratch: PathBuf::from(field(entry, "scratch")?),
469                reftable: entry.get("reftable").and_then(Value::as_bool).unwrap_or(false),
470            }),
471        },
472    };
473    let well_formed: bool = !request.id.is_empty()
474        && request.id.bytes().all(|byte| { return byte.is_ascii_alphanumeric() || byte == b'-'; })
475        // A detached head is moved as `HEAD` itself.
476        && (request.reference.starts_with("refs/") || request.reference == "HEAD")
477        && !request.reference.contains(['\0', '\n', ' '])
478        && is_object_name(&request.base)
479        // A branch that does not exist yet has no commit to build the tree from, so only a commit built elsewhere names it.
480        && (!is_zero_object(&request.base) || request.commit.is_some())
481        && request.repository.is_absolute()
482        && request.common_dir.is_absolute()
483        && request.git_dir.is_absolute()
484        && !request.git_dir.to_string_lossy().contains('\0')
485        && request.index.is_absolute()
486        && !request.index.to_string_lossy().contains('\0')
487        && !request.committer.contains(['\0', '\n'])
488        && request.author.as_deref().is_none_or(|author| { return !author.contains(['\0', '\n']); })
489        // An amend the service builds names the tree it makes; a commit built elsewhere is named whole; any other request
490        //  names neither, so the three cannot be mixed up.
491        && (request.amend && request.commit.is_none()) == request.tree.is_some()
492        && request.tree.as_deref().is_none_or(is_object_name)
493        && request.commit.as_deref().is_none_or(is_object_name)
494        && request.cleanup.as_ref().is_none_or(|cleanup| {
495            return request.commit.is_some()
496                && cleanup.git_dir.is_absolute()
497                && cleanup.scratch.is_absolute()
498                && cleanup.scratch.starts_with(&cleanup.git_dir)
499                && !cleanup.git_dir.to_string_lossy().contains('\0')
500                && !cleanup.scratch.to_string_lossy().contains('\0');
501        });
502    if !well_formed {
503        return Err(String::from("request is not acceptable"));
504    }
505    return Ok(ClientMessage::Commit(Box::new(request)));
506}
507
508/// What:
509///  The line for a service message.
510/// Why:
511///   The same one-object form in the other direction.
512///
513/// In TS you'd write (pseudocode):
514/// ```ts
515/// JSON.stringify(message)
516/// ```
517pub fn encode_service(message: &ServiceMessage) -> String {
518    return match message {
519        ServiceMessage::Welcome { version, release } => {
520            let mut entries: Vec<(&str, Value)> = vec![("type", Value::from("welcome")), ("version", Value::from(*version))];
521            if let Some(text) = release {
522                entries.push(("release", Value::from(text.as_str())));
523            }
524            object(entries)
525        }
526        ServiceMessage::Position { number, of } => {
527            object(vec![("type", Value::from("position")), ("number", Value::from(*number)), ("of", Value::from(*of))])
528        }
529        ServiceMessage::Landed { id, commit, already } => object(vec![
530            ("type", Value::from("landed")),
531            ("id", Value::from(id.as_str())),
532            ("commit", Value::from(commit.as_str())),
533            ("already", Value::from(*already)),
534        ]),
535        ServiceMessage::RetryPrepare { id, base, landed } => object(vec![
536            ("type", Value::from("retry-prepare")),
537            ("id", Value::from(id.as_str())),
538            ("base", Value::from(base.as_str())),
539            (
540                "landed",
541                Value::Array(
542                    landed
543                        .iter()
544                        .map(|entry| {
545                            return Value::Object(Map::from_iter([
546                                (String::from("commit"), Value::from(entry.commit.as_str())),
547                                (String::from("captured"), Value::from(entry.captured)),
548                            ]));
549                        })
550                        .collect(),
551                ),
552            ),
553        ]),
554        ServiceMessage::Waiting { text } => object(vec![("type", Value::from("waiting")), ("text", Value::from(text.as_str()))]),
555        ServiceMessage::Holder { who } => object(vec![("type", Value::from("holder")), ("who", Value::from(who.as_str()))]),
556        ServiceMessage::Failed { id, code } => {
557            object(vec![("type", Value::from("failed")), ("id", Value::from(id.as_str())), ("code", Value::from(code.word()))])
558        }
559    };
560}
561
562/// What:
563///  Reads one service line.
564/// Why:
565///   The client checks what it is told as strictly as the service checks what it is sent.
566///
567/// In TS you'd write (pseudocode):
568/// ```ts
569/// decodeService(line)
570/// ```
571pub fn decode_service(line: &str) -> Result<ServiceMessage, String> {
572    if line.len() > MAX_LINE_BYTES {
573        return Err(String::from("line is too long"));
574    }
575    let value: Value = serde_json::from_str(line).map_err(|error| { return error.to_string(); })?;
576    let number = |name: &str| -> Result<usize, String> {
577        return value.get(name).and_then(Value::as_u64).and_then(|n| { return usize::try_from(n).ok(); })
578            .ok_or_else(|| { return format!("field {name} is missing"); });
579    };
580    return match field(&value, "type")? {
581        "welcome" => Ok(ServiceMessage::Welcome {
582            version: u32::try_from(number("version")?).map_err(|_| { return String::from("version is out of range"); })?,
583            release: value.get("release").and_then(Value::as_str).map(String::from),
584        }),
585        "position" => Ok(ServiceMessage::Position { number: number("number")?, of: number("of")? }),
586        "landed" => Ok(ServiceMessage::Landed {
587            id: field(&value, "id")?.to_string(),
588            commit: field(&value, "commit")?.to_string(),
589            already: value.get("already").and_then(Value::as_bool).unwrap_or(false),
590        }),
591        "retry-prepare" => Ok(ServiceMessage::RetryPrepare {
592            id: field(&value, "id")?.to_string(),
593            base: field(&value, "base")?.to_string(),
594            // A service that sends no list tells nothing about the order of captures.
595            landed: value
596                .get("landed")
597                .and_then(Value::as_array)
598                .map(|entries| {
599                    return entries
600                        .iter()
601                        .filter_map(|entry| {
602                            return Some(LandedCapture {
603                                commit: entry.get("commit").and_then(Value::as_str).filter(|name| { return is_object_name(name); })?.to_string(),
604                                captured: entry.get("captured").and_then(Value::as_u64)?,
605                            });
606                        })
607                        .collect();
608                })
609                .unwrap_or_default(),
610        }),
611        "waiting" => Ok(ServiceMessage::Waiting { text: field(&value, "text")?.to_string() }),
612        "holder" => Ok(ServiceMessage::Holder { who: field(&value, "who")?.to_string() }),
613        "failed" => Ok(ServiceMessage::Failed {
614            id: field(&value, "id")?.to_string(),
615            code: FailureCode::from_word(field(&value, "code")?).ok_or("unknown failure code")?,
616        }),
617        other => Err(format!("unknown message type {other}")),
618    };
619}
620
621/// Protocol controls stay out of the release executable.
622#[cfg(test)]
623#[path = "service_protocol_tests.rs"]
624mod tests;