Skip to main content

gunnar_sendpack/
command.rs

1//! The command list: `<old-oid> <new-oid> <refname>`, written by the client
2//! and read by the server.
3//!
4//! # Reference names are bytes
5//!
6//! `refs/heads/\xff` is a name git will create, advertise and serve, so
7//! [`PushCommand::name`] is a [`BString`] and [`parse_line`] splits on **bytes**.
8//!
9//! Routing the line through `String::from_utf8_lossy` first — which is what a
10//! `String` field forces — replaces every invalid byte with U+FFFD *before* the
11//! name is validated. A non-UTF-8 name is then accepted and stored under a name
12//! the client never asked for, and any two names differing only in their invalid
13//! bytes collapse onto the same reference, so a push to one silently overwrites
14//! the other. That was a real bug in gunnar's server. The lossy rendering
15//! survives only in the error messages here, where it is read by a human and
16//! not by a store.
17
18use std::collections::HashSet;
19
20use bstr::{BString, ByteSlice};
21use gix_hash::{Kind, ObjectId};
22
23use crate::capabilities;
24use crate::error::{Error, Result};
25
26/// One line of the command list: move `name` from `old` to `new`.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct PushCommand {
29    /// Full ref name on the remote.
30    pub name: BString,
31    /// What the client believes the remote currently has. The remote compares
32    /// it and rejects on mismatch; this field **is** the compare-and-swap.
33    pub old: ObjectId,
34    /// What it should become. Null means delete.
35    pub new: ObjectId,
36}
37
38impl PushCommand {
39    /// Move `name` from `old` to `new`.
40    pub fn update(name: impl Into<BString>, old: ObjectId, new: ObjectId) -> Self {
41        PushCommand {
42            name: name.into(),
43            old,
44            new,
45        }
46    }
47
48    /// Create `name` at `new`, with the null oid as `<old>`.
49    pub fn create(name: impl Into<BString>, new: ObjectId) -> Self {
50        PushCommand {
51            name: name.into(),
52            old: ObjectId::null(new.kind()),
53            new,
54        }
55    }
56
57    /// Delete `name`, which the remote currently has at `old`.
58    pub fn delete(name: impl Into<BString>, old: ObjectId) -> Self {
59        PushCommand {
60            name: name.into(),
61            new: ObjectId::null(old.kind()),
62            old,
63        }
64    }
65
66    /// True when this removes the ref.
67    pub fn is_delete(&self) -> bool {
68        self.new.is_null()
69    }
70
71    /// True when the remote is not expected to have this ref yet.
72    pub fn is_create(&self) -> bool {
73        self.old.is_null()
74    }
75
76    /// The wire form, without the capability suffix and without a trailing
77    /// newline.
78    pub fn line(&self) -> Vec<u8> {
79        let mut out = format!("{} {} ", self.old.to_hex(), self.new.to_hex()).into_bytes();
80        out.extend_from_slice(self.name.as_slice());
81        out
82    }
83}
84
85/// A `shallow <oid>` line, which a shallow client sends **before** its commands
86/// and which is not a command.
87pub const SHALLOW_PREFIX: &[u8] = b"shallow ";
88
89/// Parse one command line, having already had its capability suffix removed by
90/// [`capabilities::split`].
91///
92/// `hash_kind` is the repository's, and an oid of the wrong width is refused
93/// here rather than downstream: a 40-hex id in a sha256 repository names
94/// nothing, and accepting it produces a rejection whose message blames the
95/// reference instead of the object format.
96pub fn parse_line(line: &[u8], hash_kind: Kind) -> Result<PushCommand> {
97    let mut parts = line.splitn(3, |&b| b == b' ');
98    let (Some(old), Some(new), Some(name)) = (parts.next(), parts.next(), parts.next()) else {
99        return Err(Error::protocol(format!(
100            "{:?} is not an `<old> <new> <ref>` command",
101            line.as_bstr()
102        )));
103    };
104    let parse = |hex: &[u8]| -> Result<ObjectId> {
105        let id = ObjectId::from_hex(hex).map_err(|err| {
106            Error::protocol(format!("{:?} is not an object id: {err}", hex.as_bstr()))
107        })?;
108        if id.kind() != hash_kind {
109            return Err(Error::ObjectFormat {
110                ours: crate::advertisement::object_format_name(hash_kind).unwrap_or("unsupported"),
111                theirs: crate::advertisement::object_format_name(id.kind())
112                    .unwrap_or("unsupported"),
113            });
114        }
115        Ok(id)
116    };
117    let (old, new) = (parse(old)?, parse(new)?);
118    if old.is_null() && new.is_null() {
119        return Err(Error::protocol(format!(
120            "{:?} has a null old AND a null new oid, which is neither a create, \
121             an update nor a delete",
122            name.as_bstr()
123        )));
124    }
125    Ok(PushCommand {
126        name: name.into(),
127        old,
128        new,
129    })
130}
131
132/// Render the command list as payload lines, **without** pkt-line framing,
133/// without the terminating flush-pkt and **without the trailing newline**: the
134/// caller owns the framer.
135///
136/// The capabilities ride on the **first line only**, after a NUL.
137///
138/// The newline is the framer's, not this function's, and every `lines` in this
139/// crate agrees on that. git frames these as *text* pkt-lines, which is
140/// `<length><payload>\n`; if the payload carried its own newline as well, one
141/// end of this crate would emit two and the other would chomp one, and the
142/// difference is invisible until a peer that does not chomp reads a reference
143/// name with a newline glued to it.
144pub fn lines(commands: &[PushCommand], caps: &[String]) -> Result<Vec<Vec<u8>>> {
145    if commands.is_empty() {
146        return Err(Error::invalid(
147            "a push with no commands has nothing to say; send nothing instead",
148        ));
149    }
150    let mut seen: HashSet<&[u8]> = HashSet::new();
151    for c in commands {
152        if !seen.insert(c.name.as_slice()) {
153            return Err(Error::invalid(format!(
154                "the command list names {:?} twice; the remote would apply one of \
155                 them and the client could not say which",
156                c.name.as_bstr()
157            )));
158        }
159        if c.is_delete() && c.is_create() {
160            return Err(Error::invalid(format!(
161                "{:?} has a null old AND a null new oid, which is neither a create, \
162                 an update nor a delete",
163                c.name.as_bstr()
164            )));
165        }
166        if c.name.contains(&b'\n') || c.name.contains(&0) {
167            return Err(Error::invalid(format!(
168                "the reference name {:?} contains a newline or a NUL, either of \
169                 which would be read as the end of the line",
170                c.name.as_bstr()
171            )));
172        }
173    }
174
175    let mut out = Vec::with_capacity(commands.len());
176    for (i, c) in commands.iter().enumerate() {
177        let mut line = c.line();
178        if i == 0 {
179            capabilities::attach(&mut line, caps);
180        }
181        out.push(line);
182    }
183    Ok(out)
184}
185
186/// Render the push-options section as payload lines.
187pub fn push_option_lines(options: &[String]) -> Result<Vec<Vec<u8>>> {
188    options
189        .iter()
190        .map(|opt| {
191            if opt.contains('\n') {
192                return Err(Error::invalid(format!(
193                    "push option {opt:?} contains a newline, which the wire cannot carry"
194                )));
195            }
196            Ok(opt.as_bytes().to_vec())
197        })
198        .collect()
199}