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 std::path::PathBuf;
16
17/// The protocol version this build speaks; a service and a client agree when their versions are equal.
18pub const PROTOCOL_VERSION: u32 = 4;
19
20/// The longest line either side accepts, in bytes.
21pub const MAX_LINE_BYTES: usize = 1 << 20;
22
23/// The most paths one request may change.
24pub const MAX_CHANGES: usize = 4096;
25
26/// Modes a change may give a file.
27pub const ALLOWED_MODES: [u32; 3] = [0o100_644, 0o100_755, 0o120_000];
28
29/// What:
30///  One path a commit changes: a file to set, or a file to delete.
31/// Why:
32///   The client has already written the file's content into the repository's object store, so the service names the
33///  object and never reads the user's worktree.
34///
35/// In TS you'd write (pseudocode):
36/// ```ts
37/// type Change = { path: string; mode: number; blob?: string }; // no blob means delete
38/// ```
39#[derive(Clone, Debug, Eq, PartialEq)]
40pub struct Change {
41    /// Repository-relative path with `/` separators.
42    pub path: String,
43    /// File mode, one of `ALLOWED_MODES`.
44    pub mode: u32,
45    /// Object name of the content, or nothing for a deletion.
46    pub blob: Option<String>,
47}
48
49/// What:
50///  A request to land one commit.
51/// Why:
52///   Every field the service needs to land it on the current branch without asking the client anything.
53///
54/// In TS you'd write (pseudocode):
55/// ```ts
56/// type CommitRequest = { id; repository; commonDir; reference; base; changes; message; author?; committer };
57/// ```
58#[derive(Clone, Debug, Eq, PartialEq)]
59pub struct CommitRequest {
60    /// Random identity of this invocation, kept across reconnects.
61    pub id: String,
62    /// Worktree root whose index is updated.
63    pub repository: PathBuf,
64    /// Common git directory shared by the worktrees; the queue key.
65    pub common_dir: PathBuf,
66    /// Full name of the branch the commit goes on.
67    pub reference: String,
68    /// The commit the client prepared against.
69    pub base: String,
70    /// The paths to change.
71    pub changes: Vec<Change>,
72    /// The commit message, exact bytes.
73    pub message: String,
74    /// `Name <email> seconds zone`, or nothing to use the committer.
75    pub author: Option<String>,
76    /// `Name <email> seconds zone`.
77    pub committer: String,
78    /// Whether the commit replaces the branch tip: its parents are the base commit's parents, and it lands only while
79    ///  the branch tip is still the base.
80    pub amend: bool,
81    /// The tree of an amend, which the client wrote into the repository's object store; nothing for any other request,
82    ///  whose tree the service makes from the base and the changes.
83    pub tree: Option<String>,
84    /// A commit Git built in a scratch repository (an editor, a signature), already in the repository's object store. The
85    ///  service lands it as it is, only while the branch tip is still the base, and ignores the message, the identities
86    ///  and the tree of the request.
87    pub commit: Option<String>,
88}
89
90impl CommitRequest {
91    /// Whether the request lands alone and only while the branch tip equals its base: an amend, or a commit built elsewhere.
92    pub fn lands_alone(&self) -> bool {
93        return self.amend || self.commit.is_some();
94    }
95}
96
97/// The closed list of reasons the service gives for not landing a request.
98#[derive(Clone, Copy, Debug, Eq, PartialEq)]
99pub enum FailureCode {
100    /// The repository has no room.
101    RepositoryFull,
102    /// The repository cannot be written.
103    RepositoryReadOnly,
104    /// Another program holds the repository's index.
105    IndexInUse,
106    /// The request is not one the service accepts.
107    Rejected,
108    /// The branch moved too often while the request waited.
109    BranchKeepsChanging,
110    /// The branch is no longer where the commit was prepared, and the commit cannot be made again on another tip.
111    BranchMoved,
112    /// Anything else the service could not resolve.
113    Unavailable,
114}
115
116impl FailureCode {
117    /// The word on the wire.
118    pub fn word(self) -> &'static str {
119        return match self {
120            Self::RepositoryFull => "repository-full",
121            Self::RepositoryReadOnly => "repository-read-only",
122            Self::IndexInUse => "index-in-use",
123            Self::Rejected => "rejected",
124            Self::BranchKeepsChanging => "branch-keeps-changing",
125            Self::BranchMoved => "branch-moved",
126            Self::Unavailable => "unavailable",
127        };
128    }
129
130    /// Every code, so a test reaches all of them.
131    pub const ALL: [Self; 7] = [
132        Self::RepositoryFull,
133        Self::RepositoryReadOnly,
134        Self::IndexInUse,
135        Self::Rejected,
136        Self::BranchKeepsChanging,
137        Self::BranchMoved,
138        Self::Unavailable,
139    ];
140
141    /// The code for a word, nothing for an unknown word.
142    pub fn from_word(word: &str) -> Option<Self> {
143        return Self::ALL.into_iter().find(|code| { return code.word() == word; });
144    }
145}
146
147/// What a client sends.
148#[derive(Clone, Debug, Eq, PartialEq)]
149pub enum ClientMessage {
150    /// Land this commit.
151    Commit(Box<CommitRequest>),
152    /// Finish the queue and exit, so the manager starts the current binary.
153    Retire,
154    /// Work items were written for this repository; run what is pending.
155    Work {
156        /// The repository's common Git directory, which holds the work directory.
157        common_dir: PathBuf,
158    },
159}
160
161/// What the service sends.
162#[derive(Clone, Debug, Eq, PartialEq)]
163pub enum ServiceMessage {
164    /// First line of every connection: the service's protocol version.
165    Welcome { version: u32 },
166    /// The request is queued behind others.
167    Position { number: usize, of: usize },
168    /// The commit is on the branch; `already` when an earlier request with the same identity landed it.
169    Landed { id: String, commit: String, already: bool },
170    /// A named path changed on the branch since the base; prepare again from `base`.
171    RetryPrepare { id: String, base: String },
172    /// The request was not landed.
173    Failed { id: String, code: FailureCode },
174}
175
176fn field<'a>(value: &'a Value, name: &str) -> Result<&'a str, String> {
177    return value.get(name).and_then(Value::as_str).ok_or_else(|| { return format!("field {name} is missing or not text"); });
178}
179
180/// Whether `text` is a full object name in hexadecimal (40 or 64 digits).
181pub fn is_object_name(text: &str) -> bool {
182    return (text.len() == 40 || text.len() == 64) && text.bytes().all(|byte| { return byte.is_ascii_hexdigit(); });
183}
184
185/// Whether `path` is a plain repository-relative path: no leading slash, no empty, `.` or `..` part, no NUL or newline.
186pub fn is_plain_path(path: &str) -> bool {
187    return !path.is_empty()
188        && !path.starts_with('/')
189        && !path.contains(['\0', '\n'])
190        && path.split('/').all(|part| { return !part.is_empty() && part != "." && part != ".." && part != ".git"; });
191}
192
193/// One JSON object, written on one line from its entries.
194fn object(entries: Vec<(&str, Value)>) -> String {
195    let mut map: Map<String, Value> = Map::new();
196    for (key, value) in entries {
197        map.insert(key.to_string(), value);
198    }
199    return Value::Object(map).to_string();
200}
201
202/// What:
203///  The line for a client message.
204/// Why:
205///   One object per line, no newline inside any field.
206///
207/// In TS you'd write (pseudocode):
208/// ```ts
209/// JSON.stringify(message)
210/// ```
211pub fn encode_client(message: &ClientMessage) -> String {
212    return match message {
213        ClientMessage::Retire => object(vec![("type", Value::from("retire")), ("version", Value::from(PROTOCOL_VERSION))]),
214        ClientMessage::Work { common_dir } => object(vec![
215            ("type", Value::from("work")),
216            ("version", Value::from(PROTOCOL_VERSION)),
217            ("commonDir", Value::from(common_dir.to_string_lossy().as_ref())),
218        ]),
219        ClientMessage::Commit(request) => object(vec![
220            ("type", Value::from("commit")),
221            ("version", Value::from(PROTOCOL_VERSION)),
222            ("id", Value::from(request.id.as_str())),
223            ("repository", Value::from(request.repository.to_string_lossy().as_ref())),
224            ("commonDir", Value::from(request.common_dir.to_string_lossy().as_ref())),
225            ("reference", Value::from(request.reference.as_str())),
226            ("base", Value::from(request.base.as_str())),
227            (
228                "changes",
229                Value::Array(
230                    request
231                        .changes
232                        .iter()
233                        .map(|change| {
234                            return Value::Object(Map::from_iter([
235                                (String::from("path"), Value::from(change.path.as_str())),
236                                (String::from("mode"), Value::from(change.mode)),
237                                (String::from("blob"), change.blob.as_deref().map_or(Value::Null, Value::from)),
238                            ]));
239                        })
240                        .collect(),
241                ),
242            ),
243            ("message", Value::from(request.message.as_str())),
244            ("author", request.author.as_deref().map_or(Value::Null, Value::from)),
245            ("committer", Value::from(request.committer.as_str())),
246            ("amend", Value::from(request.amend)),
247            ("tree", request.tree.as_deref().map_or(Value::Null, Value::from)),
248            ("commit", request.commit.as_deref().map_or(Value::Null, Value::from)),
249        ]),
250    };
251}
252
253/// What:
254///  Reads and checks one client line.
255/// Why:
256///   The service trusts nothing from a socket: sizes, names, paths, modes and the object names are all checked.
257///
258/// In TS you'd write (pseudocode):
259/// ```ts
260/// decodeClient(line) // throws on anything that is not a well-formed message
261/// ```
262pub fn decode_client(line: &str) -> Result<ClientMessage, String> {
263    if line.len() > MAX_LINE_BYTES {
264        return Err(String::from("line is too long"));
265    }
266    let value: Value = serde_json::from_str(line).map_err(|error| { return error.to_string(); })?;
267    if value.get("version").and_then(Value::as_u64) != Some(u64::from(PROTOCOL_VERSION)) {
268        return Err(String::from("unsupported version"));
269    }
270    match field(&value, "type")? {
271        "retire" => return Ok(ClientMessage::Retire),
272        "work" => {
273            let common_dir: PathBuf = PathBuf::from(field(&value, "commonDir")?);
274            if !common_dir.is_absolute() || common_dir.to_string_lossy().contains('\0') {
275                return Err(String::from("work request is not acceptable"));
276            }
277            return Ok(ClientMessage::Work { common_dir });
278        }
279        "commit" => {}
280        other => return Err(format!("unknown message type {other}")),
281    }
282    let mut changes: Vec<Change> = Vec::new();
283    for entry in value.get("changes").and_then(Value::as_array).ok_or("changes is missing")? {
284        let path: &str = field(entry, "path")?;
285        let mode: u32 = u32::try_from(entry.get("mode").and_then(Value::as_u64).ok_or("mode is missing")?)
286            .map_err(|_| { return String::from("mode is out of range"); })?;
287        let blob: Option<String> = entry.get("blob").and_then(Value::as_str).map(str::to_string);
288        if !is_plain_path(path) || !ALLOWED_MODES.contains(&mode) || blob.as_deref().is_some_and(|name| { return !is_object_name(name); }) {
289            return Err(format!("change for {path:?} is not acceptable"));
290        }
291        changes.push(Change { path: path.to_string(), mode, blob });
292    }
293    if changes.len() > MAX_CHANGES {
294        return Err(String::from("number of changes is not acceptable"));
295    }
296    let request = CommitRequest {
297        id: field(&value, "id")?.to_string(),
298        repository: PathBuf::from(field(&value, "repository")?),
299        common_dir: PathBuf::from(field(&value, "commonDir")?),
300        reference: field(&value, "reference")?.to_string(),
301        base: field(&value, "base")?.to_string(),
302        changes,
303        message: field(&value, "message")?.to_string(),
304        author: value.get("author").and_then(Value::as_str).map(str::to_string),
305        committer: field(&value, "committer")?.to_string(),
306        amend: value.get("amend").and_then(Value::as_bool).unwrap_or(false),
307        tree: value.get("tree").and_then(Value::as_str).map(str::to_string),
308        commit: value.get("commit").and_then(Value::as_str).map(str::to_string),
309    };
310    let well_formed: bool = !request.id.is_empty()
311        && request.id.bytes().all(|byte| { return byte.is_ascii_alphanumeric() || byte == b'-'; })
312        && request.reference.starts_with("refs/")
313        && !request.reference.contains(['\0', '\n', ' '])
314        && is_object_name(&request.base)
315        && request.repository.is_absolute()
316        && request.common_dir.is_absolute()
317        && !request.committer.contains(['\0', '\n'])
318        && request.author.as_deref().is_none_or(|author| { return !author.contains(['\0', '\n']); })
319        // An amend the service builds names the tree it makes; a commit built elsewhere is named whole; any other request
320        //  names neither, so the three cannot be mixed up.
321        && (request.amend && request.commit.is_none()) == request.tree.is_some()
322        && request.tree.as_deref().is_none_or(is_object_name)
323        && request.commit.as_deref().is_none_or(is_object_name);
324    if !well_formed {
325        return Err(String::from("request is not acceptable"));
326    }
327    return Ok(ClientMessage::Commit(Box::new(request)));
328}
329
330/// What:
331///  The line for a service message.
332/// Why:
333///   The same one-object form in the other direction.
334///
335/// In TS you'd write (pseudocode):
336/// ```ts
337/// JSON.stringify(message)
338/// ```
339pub fn encode_service(message: &ServiceMessage) -> String {
340    return match message {
341        ServiceMessage::Welcome { version } => object(vec![("type", Value::from("welcome")), ("version", Value::from(*version))]),
342        ServiceMessage::Position { number, of } => {
343            object(vec![("type", Value::from("position")), ("number", Value::from(*number)), ("of", Value::from(*of))])
344        }
345        ServiceMessage::Landed { id, commit, already } => object(vec![
346            ("type", Value::from("landed")),
347            ("id", Value::from(id.as_str())),
348            ("commit", Value::from(commit.as_str())),
349            ("already", Value::from(*already)),
350        ]),
351        ServiceMessage::RetryPrepare { id, base } => {
352            object(vec![("type", Value::from("retry-prepare")), ("id", Value::from(id.as_str())), ("base", Value::from(base.as_str()))])
353        }
354        ServiceMessage::Failed { id, code } => {
355            object(vec![("type", Value::from("failed")), ("id", Value::from(id.as_str())), ("code", Value::from(code.word()))])
356        }
357    };
358}
359
360/// What:
361///  Reads one service line.
362/// Why:
363///   The client checks what it is told as strictly as the service checks what it is sent.
364///
365/// In TS you'd write (pseudocode):
366/// ```ts
367/// decodeService(line)
368/// ```
369pub fn decode_service(line: &str) -> Result<ServiceMessage, String> {
370    if line.len() > MAX_LINE_BYTES {
371        return Err(String::from("line is too long"));
372    }
373    let value: Value = serde_json::from_str(line).map_err(|error| { return error.to_string(); })?;
374    let number = |name: &str| -> Result<usize, String> {
375        return value.get(name).and_then(Value::as_u64).and_then(|n| { return usize::try_from(n).ok(); })
376            .ok_or_else(|| { return format!("field {name} is missing"); });
377    };
378    return match field(&value, "type")? {
379        "welcome" => Ok(ServiceMessage::Welcome {
380            version: u32::try_from(number("version")?).map_err(|_| { return String::from("version is out of range"); })?,
381        }),
382        "position" => Ok(ServiceMessage::Position { number: number("number")?, of: number("of")? }),
383        "landed" => Ok(ServiceMessage::Landed {
384            id: field(&value, "id")?.to_string(),
385            commit: field(&value, "commit")?.to_string(),
386            already: value.get("already").and_then(Value::as_bool).unwrap_or(false),
387        }),
388        "retry-prepare" => Ok(ServiceMessage::RetryPrepare { id: field(&value, "id")?.to_string(), base: field(&value, "base")?.to_string() }),
389        "failed" => Ok(ServiceMessage::Failed {
390            id: field(&value, "id")?.to_string(),
391            code: FailureCode::from_word(field(&value, "code")?).ok_or("unknown failure code")?,
392        }),
393        other => Err(format!("unknown message type {other}")),
394    };
395}
396
397/// Protocol controls stay out of the release executable.
398#[cfg(test)]
399#[path = "service_protocol_tests.rs"]
400mod tests;