1use serde_json::{Map, Value};
15use super::service_review_key::ReviewKey;
16use std::path::{Path, PathBuf};
17
18pub const PROTOCOL_VERSION: u32 = 5;
20
21pub const RELEASE: &str = env!("CARGO_PKG_VERSION");
25
26pub const MAX_LINE_BYTES: usize = 1 << 20;
28
29pub const MAX_CHANGES: usize = 4096;
31
32pub const ALLOWED_MODES: [u32; 3] = [0o100_644, 0o100_755, 0o120_000];
34
35#[derive(Clone, Debug, Eq, PartialEq)]
46pub struct Change {
47 pub path: String,
49 pub mode: u32,
51 pub blob: Option<String>,
53}
54
55#[derive(Clone, Debug, Eq, PartialEq)]
68pub struct Cleanup {
69 pub git_dir: PathBuf,
71 pub scratch: PathBuf,
73 pub reftable: bool,
75}
76
77#[derive(Clone, Debug, Eq, PartialEq)]
87pub struct CommitRequest {
88 pub id: String,
90 pub repository: PathBuf,
92 pub common_dir: PathBuf,
94 pub git_dir: PathBuf,
97 pub reference: String,
99 pub base: String,
101 pub changes: Vec<Change>,
103 pub message: String,
105 pub author: Option<String>,
107 pub committer: String,
109 pub amend: bool,
112 pub tree: Option<String>,
115 pub commit: Option<String>,
119 pub cleanup: Option<Cleanup>,
121 pub index: PathBuf,
123 pub captured: u64,
127}
128
129#[derive(Clone, Debug, Eq, PartialEq)]
131pub struct LandedCapture {
132 pub commit: String,
134 pub captured: u64,
136}
137
138pub fn is_zero_object(name: &str) -> bool {
140 return is_object_name(name) && name.bytes().all(|byte| { return byte == b'0'; });
141}
142
143impl CommitRequest {
144 pub fn lands_alone(&self) -> bool {
146 return self.amend || self.commit.is_some();
147 }
148}
149
150#[derive(Clone, Copy, Debug, Eq, PartialEq)]
152pub enum FailureCode {
153 RepositoryFull,
155 RepositoryReadOnly,
157 IndexInUse,
159 Rejected,
161 BranchKeepsChanging,
163 BranchMoved,
165 Unavailable,
167}
168
169impl FailureCode {
170 pub fn word(self) -> &'static str {
172 return match self {
173 Self::RepositoryFull => "repository-full",
174 Self::RepositoryReadOnly => "repository-read-only",
175 Self::IndexInUse => "index-in-use",
176 Self::Rejected => "rejected",
177 Self::BranchKeepsChanging => "branch-keeps-changing",
178 Self::BranchMoved => "branch-moved",
179 Self::Unavailable => "unavailable",
180 };
181 }
182
183 pub const ALL: [Self; 7] = [
185 Self::RepositoryFull,
186 Self::RepositoryReadOnly,
187 Self::IndexInUse,
188 Self::Rejected,
189 Self::BranchKeepsChanging,
190 Self::BranchMoved,
191 Self::Unavailable,
192 ];
193
194 pub fn from_word(word: &str) -> Option<Self> {
196 return Self::ALL.into_iter().find(|code| { return code.word() == word; });
197 }
198}
199
200#[derive(Clone, Debug, Eq, PartialEq)]
202pub enum ClientMessage {
203 Commit(Box<CommitRequest>),
205 Retire,
207 Scratch {
210 git_dir: PathBuf,
212 path: PathBuf,
214 },
215 Work {
217 git_dir: PathBuf,
219 review_key: Option<ReviewKey>,
222 },
223}
224
225#[derive(Clone, Debug, Eq, PartialEq)]
227pub enum ServiceMessage {
228 Welcome { version: u32, release: Option<String> },
231 Position { number: usize, of: usize },
233 Landed { id: String, commit: String, already: bool },
235 RetryPrepare { id: String, base: String, landed: Vec<LandedCapture> },
237 Failed { id: String, code: FailureCode },
239 Waiting { text: String },
241 Holder { who: String },
243}
244
245fn field<'a>(value: &'a Value, name: &str) -> Result<&'a str, String> {
246 return value.get(name).and_then(Value::as_str).ok_or_else(|| { return format!("field {name} is missing or not text"); });
247}
248
249pub fn is_object_name(text: &str) -> bool {
251 return (text.len() == 40 || text.len() == 64) && text.bytes().all(|byte| { return byte.is_ascii_hexdigit(); });
252}
253
254pub fn is_plain_path(path: &str) -> bool {
256 return !path.is_empty()
257 && !path.starts_with('/')
258 && !path.contains(['\0', '\n'])
259 && path.split('/').all(|part| { return !part.is_empty() && part != "." && part != ".." && part != ".git"; });
260}
261
262pub const SCRATCH_PREFIX: &str = "cli-git-service-";
264
265pub fn is_scratch_path(path: &Path, git_dir: &Path) -> bool {
277 let Some(name) = path.file_name().and_then(|name| { return name.to_str(); }) else { return false };
278 let identity: &str = name.strip_prefix(SCRATCH_PREFIX).unwrap_or("");
279 return git_dir.is_absolute()
280 && path.parent() == Some(git_dir)
281 && !identity.is_empty()
282 && identity.bytes().all(|byte| { return byte.is_ascii_alphanumeric() || byte == b'-'; })
283 && git_dir.components().all(|part| { return !matches!(part, std::path::Component::ParentDir | std::path::Component::CurDir); })
284 && !path.to_string_lossy().contains('\0');
285}
286
287fn object(entries: Vec<(&str, Value)>) -> String {
289 let mut map: Map<String, Value> = Map::new();
290 for (key, value) in entries {
291 map.insert(key.to_string(), value);
292 }
293 return Value::Object(map).to_string();
294}
295
296pub fn encode_retire(version: u32) -> String {
308 return object(vec![("type", Value::from("retire")), ("version", Value::from(version))]);
309}
310
311pub fn encode_client(message: &ClientMessage) -> String {
321 return match message {
322 ClientMessage::Retire => encode_retire(PROTOCOL_VERSION),
323 ClientMessage::Scratch { git_dir, path } => object(vec![
324 ("type", Value::from("scratch")),
325 ("version", Value::from(PROTOCOL_VERSION)),
326 ("gitDir", Value::from(git_dir.to_string_lossy().as_ref())),
327 ("path", Value::from(path.to_string_lossy().as_ref())),
328 ]),
329 ClientMessage::Work { git_dir, review_key } => {
330 let mut entries: Vec<(&str, Value)> = vec![
331 ("type", Value::from("work")),
332 ("version", Value::from(PROTOCOL_VERSION)),
333 ("gitDir", Value::from(git_dir.to_string_lossy().as_ref())),
334 ];
335 if let Some(key) = review_key {
336 entries.push(("reviewKey", Value::from(key.expose())));
337 }
338 object(entries)
339 }
340 ClientMessage::Commit(request) => object(vec![
341 ("type", Value::from("commit")),
342 ("version", Value::from(PROTOCOL_VERSION)),
343 ("id", Value::from(request.id.as_str())),
344 ("repository", Value::from(request.repository.to_string_lossy().as_ref())),
345 ("commonDir", Value::from(request.common_dir.to_string_lossy().as_ref())),
346 ("gitDir", Value::from(request.git_dir.to_string_lossy().as_ref())),
347 ("reference", Value::from(request.reference.as_str())),
348 ("base", Value::from(request.base.as_str())),
349 (
350 "changes",
351 Value::Array(
352 request
353 .changes
354 .iter()
355 .map(|change| {
356 return Value::Object(Map::from_iter([
357 (String::from("path"), Value::from(change.path.as_str())),
358 (String::from("mode"), Value::from(change.mode)),
359 (String::from("blob"), change.blob.as_deref().map_or(Value::Null, Value::from)),
360 ]));
361 })
362 .collect(),
363 ),
364 ),
365 ("message", Value::from(request.message.as_str())),
366 ("author", request.author.as_deref().map_or(Value::Null, Value::from)),
367 ("committer", Value::from(request.committer.as_str())),
368 ("amend", Value::from(request.amend)),
369 ("tree", request.tree.as_deref().map_or(Value::Null, Value::from)),
370 ("commit", request.commit.as_deref().map_or(Value::Null, Value::from)),
371 ("index", Value::from(request.index.to_string_lossy().as_ref())),
372 ("captured", Value::from(request.captured)),
373 (
374 "cleanup",
375 request.cleanup.as_ref().map_or(Value::Null, |cleanup| {
376 return Value::Object(Map::from_iter([
377 (String::from("gitDir"), Value::from(cleanup.git_dir.to_string_lossy().as_ref())),
378 (String::from("scratch"), Value::from(cleanup.scratch.to_string_lossy().as_ref())),
379 (String::from("reftable"), Value::from(cleanup.reftable)),
380 ]));
381 }),
382 ),
383 ]),
384 };
385}
386
387pub fn decode_client(line: &str) -> Result<ClientMessage, String> {
397 if line.len() > MAX_LINE_BYTES {
398 return Err(String::from("line is too long"));
399 }
400 let value: Value = serde_json::from_str(line).map_err(|error| { return error.to_string(); })?;
401 if field(&value, "type")? == "retire" {
404 return Ok(ClientMessage::Retire);
405 }
406 if value.get("version").and_then(Value::as_u64) != Some(u64::from(PROTOCOL_VERSION)) {
407 return Err(String::from("unsupported version"));
408 }
409 match field(&value, "type")? {
410 "retire" => return Ok(ClientMessage::Retire),
411 "scratch" => {
412 let git_dir: PathBuf = PathBuf::from(field(&value, "gitDir")?);
413 let path: PathBuf = PathBuf::from(field(&value, "path")?);
414 if !is_scratch_path(path.as_path(), git_dir.as_path()) {
415 return Err(String::from("scratch directory is not acceptable"));
416 }
417 return Ok(ClientMessage::Scratch { git_dir, path });
418 }
419 "work" => {
420 let git_dir: PathBuf = PathBuf::from(field(&value, "gitDir")?);
421 if !git_dir.is_absolute() || git_dir.to_string_lossy().contains('\0') {
422 return Err(String::from("work request is not acceptable"));
423 }
424 let review_key: Option<ReviewKey> = match value.get("reviewKey") {
425 None | Some(Value::Null) => None,
426 Some(Value::String(text)) => ReviewKey::new(text.as_str()),
427 Some(_) => return Err(String::from("work request is not acceptable")),
428 };
429 return Ok(ClientMessage::Work { git_dir, review_key });
430 }
431 "commit" => {}
432 other => return Err(format!("unknown message type {other}")),
433 }
434 let mut changes: Vec<Change> = Vec::new();
435 for entry in value.get("changes").and_then(Value::as_array).ok_or("changes is missing")? {
436 let path: &str = field(entry, "path")?;
437 let mode: u32 = u32::try_from(entry.get("mode").and_then(Value::as_u64).ok_or("mode is missing")?)
438 .map_err(|_| { return String::from("mode is out of range"); })?;
439 let blob: Option<String> = entry.get("blob").and_then(Value::as_str).map(str::to_string);
440 if !is_plain_path(path) || !ALLOWED_MODES.contains(&mode) || blob.as_deref().is_some_and(|name| { return !is_object_name(name); }) {
441 return Err(format!("change for {path:?} is not acceptable"));
442 }
443 changes.push(Change { path: path.to_string(), mode, blob });
444 }
445 if changes.len() > MAX_CHANGES {
446 return Err(String::from("number of changes is not acceptable"));
447 }
448 let request = CommitRequest {
449 id: field(&value, "id")?.to_string(),
450 repository: PathBuf::from(field(&value, "repository")?),
451 common_dir: PathBuf::from(field(&value, "commonDir")?),
452 git_dir: PathBuf::from(field(&value, "gitDir")?),
453 reference: field(&value, "reference")?.to_string(),
454 base: field(&value, "base")?.to_string(),
455 changes,
456 message: field(&value, "message")?.to_string(),
457 author: value.get("author").and_then(Value::as_str).map(str::to_string),
458 committer: field(&value, "committer")?.to_string(),
459 amend: value.get("amend").and_then(Value::as_bool).unwrap_or(false),
460 tree: value.get("tree").and_then(Value::as_str).map(str::to_string),
461 commit: value.get("commit").and_then(Value::as_str).map(str::to_string),
462 index: PathBuf::from(field(&value, "index")?),
463 captured: value.get("captured").and_then(Value::as_u64).unwrap_or(0),
464 cleanup: match value.get("cleanup").filter(|entry| { return !entry.is_null(); }) {
465 None => None,
466 Some(entry) => Some(Cleanup {
467 git_dir: PathBuf::from(field(entry, "gitDir")?),
468 scratch: PathBuf::from(field(entry, "scratch")?),
469 reftable: entry.get("reftable").and_then(Value::as_bool).unwrap_or(false),
470 }),
471 },
472 };
473 let well_formed: bool = !request.id.is_empty()
474 && request.id.bytes().all(|byte| { return byte.is_ascii_alphanumeric() || byte == b'-'; })
475 && (request.reference.starts_with("refs/") || request.reference == "HEAD")
477 && !request.reference.contains(['\0', '\n', ' '])
478 && is_object_name(&request.base)
479 && (!is_zero_object(&request.base) || request.commit.is_some())
481 && request.repository.is_absolute()
482 && request.common_dir.is_absolute()
483 && request.git_dir.is_absolute()
484 && !request.git_dir.to_string_lossy().contains('\0')
485 && request.index.is_absolute()
486 && !request.index.to_string_lossy().contains('\0')
487 && !request.committer.contains(['\0', '\n'])
488 && request.author.as_deref().is_none_or(|author| { return !author.contains(['\0', '\n']); })
489 && (request.amend && request.commit.is_none()) == request.tree.is_some()
492 && request.tree.as_deref().is_none_or(is_object_name)
493 && request.commit.as_deref().is_none_or(is_object_name)
494 && request.cleanup.as_ref().is_none_or(|cleanup| {
495 return request.commit.is_some()
496 && cleanup.git_dir.is_absolute()
497 && cleanup.scratch.is_absolute()
498 && cleanup.scratch.starts_with(&cleanup.git_dir)
499 && !cleanup.git_dir.to_string_lossy().contains('\0')
500 && !cleanup.scratch.to_string_lossy().contains('\0');
501 });
502 if !well_formed {
503 return Err(String::from("request is not acceptable"));
504 }
505 return Ok(ClientMessage::Commit(Box::new(request)));
506}
507
508pub fn encode_service(message: &ServiceMessage) -> String {
518 return match message {
519 ServiceMessage::Welcome { version, release } => {
520 let mut entries: Vec<(&str, Value)> = vec![("type", Value::from("welcome")), ("version", Value::from(*version))];
521 if let Some(text) = release {
522 entries.push(("release", Value::from(text.as_str())));
523 }
524 object(entries)
525 }
526 ServiceMessage::Position { number, of } => {
527 object(vec![("type", Value::from("position")), ("number", Value::from(*number)), ("of", Value::from(*of))])
528 }
529 ServiceMessage::Landed { id, commit, already } => object(vec![
530 ("type", Value::from("landed")),
531 ("id", Value::from(id.as_str())),
532 ("commit", Value::from(commit.as_str())),
533 ("already", Value::from(*already)),
534 ]),
535 ServiceMessage::RetryPrepare { id, base, landed } => object(vec![
536 ("type", Value::from("retry-prepare")),
537 ("id", Value::from(id.as_str())),
538 ("base", Value::from(base.as_str())),
539 (
540 "landed",
541 Value::Array(
542 landed
543 .iter()
544 .map(|entry| {
545 return Value::Object(Map::from_iter([
546 (String::from("commit"), Value::from(entry.commit.as_str())),
547 (String::from("captured"), Value::from(entry.captured)),
548 ]));
549 })
550 .collect(),
551 ),
552 ),
553 ]),
554 ServiceMessage::Waiting { text } => object(vec![("type", Value::from("waiting")), ("text", Value::from(text.as_str()))]),
555 ServiceMessage::Holder { who } => object(vec![("type", Value::from("holder")), ("who", Value::from(who.as_str()))]),
556 ServiceMessage::Failed { id, code } => {
557 object(vec![("type", Value::from("failed")), ("id", Value::from(id.as_str())), ("code", Value::from(code.word()))])
558 }
559 };
560}
561
562pub fn decode_service(line: &str) -> Result<ServiceMessage, String> {
572 if line.len() > MAX_LINE_BYTES {
573 return Err(String::from("line is too long"));
574 }
575 let value: Value = serde_json::from_str(line).map_err(|error| { return error.to_string(); })?;
576 let number = |name: &str| -> Result<usize, String> {
577 return value.get(name).and_then(Value::as_u64).and_then(|n| { return usize::try_from(n).ok(); })
578 .ok_or_else(|| { return format!("field {name} is missing"); });
579 };
580 return match field(&value, "type")? {
581 "welcome" => Ok(ServiceMessage::Welcome {
582 version: u32::try_from(number("version")?).map_err(|_| { return String::from("version is out of range"); })?,
583 release: value.get("release").and_then(Value::as_str).map(String::from),
584 }),
585 "position" => Ok(ServiceMessage::Position { number: number("number")?, of: number("of")? }),
586 "landed" => Ok(ServiceMessage::Landed {
587 id: field(&value, "id")?.to_string(),
588 commit: field(&value, "commit")?.to_string(),
589 already: value.get("already").and_then(Value::as_bool).unwrap_or(false),
590 }),
591 "retry-prepare" => Ok(ServiceMessage::RetryPrepare {
592 id: field(&value, "id")?.to_string(),
593 base: field(&value, "base")?.to_string(),
594 landed: value
596 .get("landed")
597 .and_then(Value::as_array)
598 .map(|entries| {
599 return entries
600 .iter()
601 .filter_map(|entry| {
602 return Some(LandedCapture {
603 commit: entry.get("commit").and_then(Value::as_str).filter(|name| { return is_object_name(name); })?.to_string(),
604 captured: entry.get("captured").and_then(Value::as_u64)?,
605 });
606 })
607 .collect();
608 })
609 .unwrap_or_default(),
610 }),
611 "waiting" => Ok(ServiceMessage::Waiting { text: field(&value, "text")?.to_string() }),
612 "holder" => Ok(ServiceMessage::Holder { who: field(&value, "who")?.to_string() }),
613 "failed" => Ok(ServiceMessage::Failed {
614 id: field(&value, "id")?.to_string(),
615 code: FailureCode::from_word(field(&value, "code")?).ok_or("unknown failure code")?,
616 }),
617 other => Err(format!("unknown message type {other}")),
618 };
619}
620
621#[cfg(test)]
623#[path = "service_protocol_tests.rs"]
624mod tests;