Skip to main content

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