use serde_json::{Map, Value};
use crate::service_review_key::ReviewKey;
use std::path::{Path, PathBuf};
pub const PROTOCOL_VERSION: u32 = 7;
pub const RELEASE: &str = env!("CARGO_PKG_VERSION");
pub const HEARTBEAT_EVERY: std::time::Duration = std::time::Duration::from_secs(1);
pub const HEARTBEAT_SILENCE: std::time::Duration = std::time::Duration::from_secs(5);
pub const P99_CHANGED_PATH_CHARACTERS: usize = 118;
pub const MAX_LINE_BYTES: usize = 1 << 20;
pub const MAX_CHANGES: usize = 4096;
pub const MAX_ID_BYTES: usize = 128;
pub const ALLOWED_MODES: [u32; 3] = [0o100_644, 0o100_755, 0o120_000];
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Change {
pub path: String,
pub mode: u32,
pub blob: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Cleanup {
pub git_dir: PathBuf,
pub scratch: PathBuf,
pub reftable: bool,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CommitRequest {
pub id: String,
pub repository: PathBuf,
pub common_dir: PathBuf,
pub git_dir: PathBuf,
pub reference: String,
pub base: String,
pub changes: Vec<Change>,
pub message: String,
pub author: Option<String>,
pub committer: String,
pub amend: bool,
pub tree: Option<String>,
pub commit: Option<String>,
pub cleanup: Option<Cleanup>,
pub index: PathBuf,
pub captured: u64,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct LandedCapture {
pub commit: String,
pub captured: u64,
}
pub fn is_zero_object(name: &str) -> bool {
return is_object_name(name) && name.bytes().all(|byte| { return byte == b'0'; });
}
impl CommitRequest {
pub fn lands_alone(&self) -> bool {
return self.amend || self.commit.is_some();
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FailureCode {
RepositoryFull,
RepositoryReadOnly,
IndexInUse,
Rejected,
BranchKeepsChanging,
BranchMoved,
StepTooSlow,
Unavailable,
}
impl FailureCode {
pub fn word(self) -> &'static str {
return match self {
Self::RepositoryFull => "repository-full",
Self::RepositoryReadOnly => "repository-read-only",
Self::IndexInUse => "index-in-use",
Self::Rejected => "rejected",
Self::BranchKeepsChanging => "branch-keeps-changing",
Self::BranchMoved => "branch-moved",
Self::StepTooSlow => "step-too-slow",
Self::Unavailable => "unavailable",
};
}
pub const ALL: [Self; 8] = [
Self::RepositoryFull,
Self::RepositoryReadOnly,
Self::IndexInUse,
Self::Rejected,
Self::BranchKeepsChanging,
Self::BranchMoved,
Self::StepTooSlow,
Self::Unavailable,
];
pub fn from_word(word: &str) -> Option<Self> {
return Self::ALL.into_iter().find(|code| { return code.word() == word; });
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ClientMessage {
Commit(Box<CommitRequest>),
Retire,
Withdraw {
id: String,
},
Scratch {
git_dir: PathBuf,
path: PathBuf,
},
Work {
git_dir: PathBuf,
review_key: Option<ReviewKey>,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ServiceMessage {
Welcome { version: u32, release: Option<String> },
Position { number: usize, of: usize },
Landed { id: String, commit: String, already: bool },
RetryPrepare { id: String, base: String, landed: Vec<LandedCapture> },
Failed { id: String, code: FailureCode },
Waiting { text: String },
Withdrawn { id: String },
Holder { who: String },
Heartbeat,
}
fn field<'a>(value: &'a Value, name: &str) -> Result<&'a str, String> {
return value.get(name).and_then(Value::as_str).ok_or_else(|| { return format!("field {name} is missing or not text"); });
}
pub fn is_object_name(text: &str) -> bool {
return (text.len() == 40 || text.len() == 64) && text.bytes().all(|byte| { return byte.is_ascii_hexdigit(); });
}
pub fn is_plain_path(path: &str) -> bool {
return !path.is_empty()
&& !path.starts_with('/')
&& !path.contains(['\0', '\n'])
&& path.split('/').all(|part| { return !part.is_empty() && part != "." && part != ".." && part != ".git"; });
}
pub const SCRATCH_PREFIX: &str = "cli-git-service-";
pub fn is_scratch_path(path: &Path, git_dir: &Path) -> bool {
let Some(name) = path.file_name().and_then(|name| { return name.to_str(); }) else { return false };
let identity: &str = name.strip_prefix(SCRATCH_PREFIX).unwrap_or("");
return git_dir.is_absolute()
&& path.parent() == Some(git_dir)
&& !identity.is_empty()
&& identity.bytes().all(|byte| { return byte.is_ascii_alphanumeric() || byte == b'-'; })
&& git_dir.components().all(|part| { return !matches!(part, std::path::Component::ParentDir | std::path::Component::CurDir); })
&& !path.to_string_lossy().contains('\0');
}
fn object(entries: Vec<(&str, Value)>) -> String {
let mut map: Map<String, Value> = Map::new();
for (key, value) in entries {
map.insert(key.to_string(), value);
}
return Value::Object(map).to_string();
}
pub fn encode_retire(version: u32) -> String {
return object(vec![("type", Value::from("retire")), ("version", Value::from(version))]);
}
pub fn encode_client(message: &ClientMessage) -> String {
return match message {
ClientMessage::Retire => encode_retire(PROTOCOL_VERSION),
ClientMessage::Withdraw { id } => object(vec![
("type", Value::from("withdraw")),
("version", Value::from(PROTOCOL_VERSION)),
("id", Value::from(id.as_str())),
]),
ClientMessage::Scratch { git_dir, path } => object(vec![
("type", Value::from("scratch")),
("version", Value::from(PROTOCOL_VERSION)),
("gitDir", Value::from(git_dir.to_string_lossy().as_ref())),
("path", Value::from(path.to_string_lossy().as_ref())),
]),
ClientMessage::Work { git_dir, review_key } => {
let mut entries: Vec<(&str, Value)> = vec![
("type", Value::from("work")),
("version", Value::from(PROTOCOL_VERSION)),
("gitDir", Value::from(git_dir.to_string_lossy().as_ref())),
];
if let Some(key) = review_key {
entries.push(("reviewKey", Value::from(key.expose())));
}
object(entries)
}
ClientMessage::Commit(request) => object(vec![
("type", Value::from("commit")),
("version", Value::from(PROTOCOL_VERSION)),
("id", Value::from(request.id.as_str())),
("repository", Value::from(request.repository.to_string_lossy().as_ref())),
("commonDir", Value::from(request.common_dir.to_string_lossy().as_ref())),
("gitDir", Value::from(request.git_dir.to_string_lossy().as_ref())),
("reference", Value::from(request.reference.as_str())),
("base", Value::from(request.base.as_str())),
(
"changes",
Value::Array(
request
.changes
.iter()
.map(|change| {
return Value::Object(Map::from_iter([
(String::from("path"), Value::from(change.path.as_str())),
(String::from("mode"), Value::from(change.mode)),
(String::from("blob"), change.blob.as_deref().map_or(Value::Null, Value::from)),
]));
})
.collect(),
),
),
("message", Value::from(request.message.as_str())),
("author", request.author.as_deref().map_or(Value::Null, Value::from)),
("committer", Value::from(request.committer.as_str())),
("amend", Value::from(request.amend)),
("tree", request.tree.as_deref().map_or(Value::Null, Value::from)),
("commit", request.commit.as_deref().map_or(Value::Null, Value::from)),
("index", Value::from(request.index.to_string_lossy().as_ref())),
("captured", Value::from(request.captured)),
(
"cleanup",
request.cleanup.as_ref().map_or(Value::Null, |cleanup| {
return Value::Object(Map::from_iter([
(String::from("gitDir"), Value::from(cleanup.git_dir.to_string_lossy().as_ref())),
(String::from("scratch"), Value::from(cleanup.scratch.to_string_lossy().as_ref())),
(String::from("reftable"), Value::from(cleanup.reftable)),
]));
}),
),
]),
};
}
pub fn decode_client(line: &str) -> Result<ClientMessage, String> {
if line.len() > MAX_LINE_BYTES {
return Err(String::from("line is too long"));
}
let value: Value = serde_json::from_str(line).map_err(|error| { return error.to_string(); })?;
if field(&value, "type")? == "retire" {
return Ok(ClientMessage::Retire);
}
if value.get("version").and_then(Value::as_u64) != Some(u64::from(PROTOCOL_VERSION)) {
return Err(String::from("unsupported version"));
}
match field(&value, "type")? {
"retire" => return Ok(ClientMessage::Retire),
"withdraw" => {
let id: &str = field(&value, "id")?;
if id.is_empty() || id.len() > MAX_ID_BYTES {
return Err(String::from("withdraw request is not acceptable"));
}
return Ok(ClientMessage::Withdraw { id: id.to_string() });
}
"scratch" => {
let git_dir: PathBuf = PathBuf::from(field(&value, "gitDir")?);
let path: PathBuf = PathBuf::from(field(&value, "path")?);
if !is_scratch_path(path.as_path(), git_dir.as_path()) {
return Err(String::from("scratch directory is not acceptable"));
}
return Ok(ClientMessage::Scratch { git_dir, path });
}
"work" => {
let git_dir: PathBuf = PathBuf::from(field(&value, "gitDir")?);
if !git_dir.is_absolute() || git_dir.to_string_lossy().contains('\0') {
return Err(String::from("work request is not acceptable"));
}
let review_key: Option<ReviewKey> = match value.get("reviewKey") {
None | Some(Value::Null) => None,
Some(Value::String(text)) => ReviewKey::new(text.as_str()),
Some(_) => return Err(String::from("work request is not acceptable")),
};
return Ok(ClientMessage::Work { git_dir, review_key });
}
"commit" => {}
other => return Err(format!("unknown message type {other}")),
}
let mut changes: Vec<Change> = Vec::new();
for entry in value.get("changes").and_then(Value::as_array).ok_or("changes is missing")? {
let path: &str = field(entry, "path")?;
let mode: u32 = u32::try_from(entry.get("mode").and_then(Value::as_u64).ok_or("mode is missing")?)
.map_err(|_| { return String::from("mode is out of range"); })?;
let blob: Option<String> = entry.get("blob").and_then(Value::as_str).map(str::to_string);
if !is_plain_path(path) || !ALLOWED_MODES.contains(&mode) || blob.as_deref().is_some_and(|name| { return !is_object_name(name); }) {
return Err(format!("change for {path:?} is not acceptable"));
}
changes.push(Change { path: path.to_string(), mode, blob });
}
if changes.len() > MAX_CHANGES {
return Err(String::from("number of changes is not acceptable"));
}
let request = CommitRequest {
id: field(&value, "id")?.to_string(),
repository: PathBuf::from(field(&value, "repository")?),
common_dir: PathBuf::from(field(&value, "commonDir")?),
git_dir: PathBuf::from(field(&value, "gitDir")?),
reference: field(&value, "reference")?.to_string(),
base: field(&value, "base")?.to_string(),
changes,
message: field(&value, "message")?.to_string(),
author: value.get("author").and_then(Value::as_str).map(str::to_string),
committer: field(&value, "committer")?.to_string(),
amend: value.get("amend").and_then(Value::as_bool).unwrap_or(false),
tree: value.get("tree").and_then(Value::as_str).map(str::to_string),
commit: value.get("commit").and_then(Value::as_str).map(str::to_string),
index: PathBuf::from(field(&value, "index")?),
captured: value.get("captured").and_then(Value::as_u64).unwrap_or(0),
cleanup: match value.get("cleanup").filter(|entry| { return !entry.is_null(); }) {
None => None,
Some(entry) => Some(Cleanup {
git_dir: PathBuf::from(field(entry, "gitDir")?),
scratch: PathBuf::from(field(entry, "scratch")?),
reftable: entry.get("reftable").and_then(Value::as_bool).unwrap_or(false),
}),
},
};
let well_formed: bool = !request.id.is_empty()
&& request.id.bytes().all(|byte| { return byte.is_ascii_alphanumeric() || byte == b'-'; })
&& (request.reference.starts_with("refs/") || request.reference == "HEAD")
&& !request.reference.contains(['\0', '\n', ' '])
&& is_object_name(&request.base)
&& (!is_zero_object(&request.base) || request.commit.is_some())
&& request.repository.is_absolute()
&& request.common_dir.is_absolute()
&& request.git_dir.is_absolute()
&& !request.git_dir.to_string_lossy().contains('\0')
&& request.index.is_absolute()
&& !request.index.to_string_lossy().contains('\0')
&& !request.committer.contains(['\0', '\n'])
&& request.author.as_deref().is_none_or(|author| { return !author.contains(['\0', '\n']); })
&& (request.amend && request.commit.is_none()) == request.tree.is_some()
&& request.tree.as_deref().is_none_or(is_object_name)
&& request.commit.as_deref().is_none_or(is_object_name)
&& request.cleanup.as_ref().is_none_or(|cleanup| {
return request.commit.is_some()
&& cleanup.git_dir.is_absolute()
&& cleanup.scratch.is_absolute()
&& cleanup.scratch.starts_with(&cleanup.git_dir)
&& !cleanup.git_dir.to_string_lossy().contains('\0')
&& !cleanup.scratch.to_string_lossy().contains('\0');
});
if !well_formed {
return Err(String::from("request is not acceptable"));
}
return Ok(ClientMessage::Commit(Box::new(request)));
}
pub fn encode_service(message: &ServiceMessage) -> String {
return match message {
ServiceMessage::Welcome { version, release } => {
let mut entries: Vec<(&str, Value)> = vec![("type", Value::from("welcome")), ("version", Value::from(*version))];
if let Some(text) = release {
entries.push(("release", Value::from(text.as_str())));
}
object(entries)
}
ServiceMessage::Position { number, of } => {
object(vec![("type", Value::from("position")), ("number", Value::from(*number)), ("of", Value::from(*of))])
}
ServiceMessage::Landed { id, commit, already } => object(vec![
("type", Value::from("landed")),
("id", Value::from(id.as_str())),
("commit", Value::from(commit.as_str())),
("already", Value::from(*already)),
]),
ServiceMessage::RetryPrepare { id, base, landed } => object(vec![
("type", Value::from("retry-prepare")),
("id", Value::from(id.as_str())),
("base", Value::from(base.as_str())),
(
"landed",
Value::Array(
landed
.iter()
.map(|entry| {
return Value::Object(Map::from_iter([
(String::from("commit"), Value::from(entry.commit.as_str())),
(String::from("captured"), Value::from(entry.captured)),
]));
})
.collect(),
),
),
]),
ServiceMessage::Waiting { text } => object(vec![("type", Value::from("waiting")), ("text", Value::from(text.as_str()))]),
ServiceMessage::Holder { who } => object(vec![("type", Value::from("holder")), ("who", Value::from(who.as_str()))]),
ServiceMessage::Heartbeat => object(vec![("type", Value::from("heartbeat"))]),
ServiceMessage::Withdrawn { id } => object(vec![("type", Value::from("withdrawn")), ("id", Value::from(id.as_str()))]),
ServiceMessage::Failed { id, code } => {
object(vec![("type", Value::from("failed")), ("id", Value::from(id.as_str())), ("code", Value::from(code.word()))])
}
};
}
pub fn decode_service(line: &str) -> Result<ServiceMessage, String> {
if line.len() > MAX_LINE_BYTES {
return Err(String::from("line is too long"));
}
let value: Value = serde_json::from_str(line).map_err(|error| { return error.to_string(); })?;
let number = |name: &str| -> Result<usize, String> {
return value.get(name).and_then(Value::as_u64).and_then(|n| { return usize::try_from(n).ok(); })
.ok_or_else(|| { return format!("field {name} is missing"); });
};
return match field(&value, "type")? {
"welcome" => Ok(ServiceMessage::Welcome {
version: u32::try_from(number("version")?).map_err(|_| { return String::from("version is out of range"); })?,
release: value.get("release").and_then(Value::as_str).map(String::from),
}),
"position" => Ok(ServiceMessage::Position { number: number("number")?, of: number("of")? }),
"landed" => Ok(ServiceMessage::Landed {
id: field(&value, "id")?.to_string(),
commit: field(&value, "commit")?.to_string(),
already: value.get("already").and_then(Value::as_bool).unwrap_or(false),
}),
"retry-prepare" => Ok(ServiceMessage::RetryPrepare {
id: field(&value, "id")?.to_string(),
base: field(&value, "base")?.to_string(),
landed: value
.get("landed")
.and_then(Value::as_array)
.map(|entries| {
return entries
.iter()
.filter_map(|entry| {
return Some(LandedCapture {
commit: entry.get("commit").and_then(Value::as_str).filter(|name| { return is_object_name(name); })?.to_string(),
captured: entry.get("captured").and_then(Value::as_u64)?,
});
})
.collect();
})
.unwrap_or_default(),
}),
"waiting" => Ok(ServiceMessage::Waiting { text: field(&value, "text")?.to_string() }),
"holder" => Ok(ServiceMessage::Holder { who: field(&value, "who")?.to_string() }),
"heartbeat" => Ok(ServiceMessage::Heartbeat),
"withdrawn" => Ok(ServiceMessage::Withdrawn { id: field(&value, "id")?.to_string() }),
"failed" => Ok(ServiceMessage::Failed {
id: field(&value, "id")?.to_string(),
code: FailureCode::from_word(field(&value, "code")?).ok_or("unknown failure code")?,
}),
other => Err(format!("unknown message type {other}")),
};
}
#[cfg(test)]
#[path = "service_protocol_tests.rs"]
mod tests;