use serde_json::Value;
use crate::service_review_key::ReviewKey;
use std::path::PathBuf;
use crate::service_protocol_messages_and_name_checks::{PROTOCOL_VERSION, MAX_LINE_BYTES, MAX_CHANGES, MAX_ID_BYTES, ALLOWED_MODES, Change, Cleanup, CommitRequest, LandedCapture, is_zero_object, FailureCode, ClientMessage, ServiceMessage, field, is_object_name, is_identity_byte, is_plain_path, is_scratch_path};
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_text)?;
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 = match u32::try_from(entry.get("mode").and_then(Value::as_u64).ok_or("mode is missing")?) {
Ok(found) => found,
Err(_out_of_range) => return Err(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) || matches!(blob.as_deref(), Some(name) if !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") {
Some(entry) if !entry.is_null() => 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),
}),
_ => None,
},
};
let author_clean: bool = match request.author.as_deref() {
Some(author) => !author.contains(['\0', '\n']),
None => true,
};
let cleanup_clean: bool = match request.cleanup.as_ref() {
Some(cleanup) => {
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')
}
None => true,
};
let well_formed: bool = !request.id.is_empty()
&& request.id.as_bytes().iter().all(is_identity_byte)
&& (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'])
&& author_clean
&& (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)
&& cleanup_clean;
if !well_formed {
return Err(String::from("request is not acceptable"));
}
return Ok(ClientMessage::Commit(Box::new(request)));
}
fn number_field(value: &Value, name: &str) -> Result<usize, String> {
let Some(wide) = value.get(name).and_then(Value::as_u64) else {
return Err(format!("field {name} is missing"));
};
return match usize::try_from(wide) {
Ok(count) => Ok(count),
Err(_too_large) => Err(format!("field {name} is missing")),
};
}
fn landed_captures(entries: &Vec<Value>) -> Vec<LandedCapture> {
let mut captures: Vec<LandedCapture> = Vec::new();
for entry in entries {
let Some(commit) = entry.get("commit").and_then(Value::as_str) else {
continue;
};
if !is_object_name(commit) {
continue;
}
let Some(captured) = entry.get("captured").and_then(Value::as_u64) else {
continue;
};
captures.push(LandedCapture { commit: commit.to_string(), captured });
}
return captures;
}
fn error_text(error: serde_json::Error) -> String {
return error.to_string();
}
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_text)?;
return match field(&value, "type")? {
"welcome" => Ok(ServiceMessage::Welcome {
version: match u32::try_from(number_field(&value, "version")?) {
Ok(found) => found,
Err(_out_of_range) => return Err(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_field(&value, "number")?, of: number_field(&value, "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(landed_captures)
.unwrap_or_default(),
}),
"waiting" if value.get("inside_write").and_then(Value::as_bool) == Some(true) => Ok(ServiceMessage::InsideWrite { text: field(&value, "text")?.to_string() }),
"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}")),
};
}