monochromatic-git-policy-cli 0.6.0

Repository-owned Git policy wrapper: forwards Git commands and checks commits against configured policies.
//! What:
//!  Reads one line of the protocol as a client message or a service message, checking every field.
//! Why:
//!  The service refuses a damaged or hostile line rather than guessing, and the client checks what it is told as strictly;
//!  the types and name checks come from `service_protocol_messages_and_name_checks.rs`.
//!
//! In TS you'd write (pseudocode):
//! ```ts
//! const reply = decodeResponse(await readLine(socket));
//! ```

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};

/// What:
///  Reads and checks one client line.
/// Why:
///   The service trusts nothing from a socket: sizes, names, paths, modes and the object names are all checked.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// decodeClient(line) // throws on anything that is not a well-formed message
/// ```
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)?;
    // A request to retire carries no data a version could change the meaning of, and it is the one message a client of
    //  another protocol version must still be able to send, so that service can be replaced.
    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),
        // A `cleanup` that is absent or null asks for none.
        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,
        },
    };
    // An author, when given, has no NUL or line break in it; no author is fine.
    let author_clean: bool = match request.author.as_deref() {
        Some(author) => !author.contains(['\0', '\n']),
        None => true,
    };
    // A cleanup, when asked for, belongs to a commit built elsewhere and names absolute directories without NUL; none is fine.
    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)
        // A detached head is moved as `HEAD` itself.
        && (request.reference.starts_with("refs/") || request.reference == "HEAD")
        && !request.reference.contains(['\0', '\n', ' '])
        && is_object_name(&request.base)
        // A branch that does not exist yet has no commit to build the tree from, so only a commit built elsewhere names it.
        && (!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
        // An amend the service builds names the tree it makes; a commit built elsewhere is named whole; any other request
        //  names neither, so the three cannot be mixed up.
        && (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)));
}

/// What:
///  A numeric field of a service message as a count: the field must be there and fit.
/// Why:
///  `decode_service` reads positions and versions with it; a value too large is as unusable as a missing one.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const numberField = (value, name) => { const n = value[name]; if (!Number.isSafeInteger(n)) throw new Error(`field ${name} is missing`); return n; };
/// ```
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")),
    };
}

/// What:
///  The captures of a `landed` list: the entries that name an object and carry a capture number; others are left out.
/// Why:
///  `decode_service` reads the list of a `retry-prepare` message with it, and an entry it cannot use is skipped, not an error.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// entries.flatMap((entry) => isObjectName(entry.commit) && typeof entry.captured === 'number' ? [{ commit: entry.commit, captured: entry.captured }] : [])
/// ```
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;
}

/// What:
///  A JSON parse error as its message text.
/// Why:
///  The decoders report every failure as text.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// const errorText = (error: Error) => error.message;
/// ```
fn error_text(error: serde_json::Error) -> String {
    return error.to_string();
}

/// What:
///  Reads one service line.
/// Why:
///   The client checks what it is told as strictly as the service checks what it is sent.
///
/// In TS you'd write (pseudocode):
/// ```ts
/// decodeService(line)
/// ```
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(),
            // A service that sends no list tells nothing about the order of captures.
            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}")),
    };
}