thirdshift 0.16.5

Turn a GitHub issue into a ready-for-review pull request, unattended
//! Protect the inputs and commits under review while allowing session work.

use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::io::Read;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::Path;

use anyhow::{Context, Result};
use sha2::{Digest, Sha256};

use super::super::{format::Task, repository};
use crate::prompt;

pub(super) struct Snapshot {
    base: String,
    change: String,
    inputs: BTreeSet<String>,
    digest: Vec<u8>,
}

impl Snapshot {
    pub fn capture(repo: &Path, task: &Task) -> Result<Self> {
        let inputs = task.trial.as_ref().context("missing Trial inputs")?;
        let mut snapshot = Self {
            base: format!("refs/heads/{}", inputs.base),
            change: repository::git(repo, &["rev-parse", "HEAD"])?,
            inputs: task.inputs.keys().cloned().collect(),
            digest: Vec::new(),
        };
        snapshot.inputs.insert(prompt::ISSUE_FILE.into());
        snapshot.digest = snapshot.digest(repo)?;
        Ok(snapshot)
    }

    pub fn unchanged(&self, repo: &Path) -> Result<bool> {
        Ok(self.digest(repo)? == self.digest)
    }

    fn digest(&self, repo: &Path) -> Result<Vec<u8>> {
        let mut digest = Sha256::new();
        hash(
            &mut digest,
            repository::git(repo, &["rev-parse", &self.base])?.as_bytes(),
        );
        // Grafts and shallow boundaries can change the reviewed ancestry
        // without changing commit objects or replacement refs.
        hash(
            &mut digest,
            repository::git(
                repo,
                &[
                    "rev-list",
                    "--parents",
                    "--no-walk",
                    &self.base,
                    &self.change,
                ],
            )?
            .as_bytes(),
        );
        // Pin the prepared change object, not the Issue branch's moving HEAD.
        // Include both commits' trees and blobs, even binary or ignored paths,
        // so corrupting an object cannot hide behind its unchanged object ID.
        let objects = repository::git(
            repo,
            &[
                "rev-list",
                "--objects",
                "--no-object-names",
                "--no-walk",
                &self.base,
                &self.change,
            ],
        )?;
        hash(
            &mut digest,
            &repository::bytes(
                repo,
                &["cat-file", "--batch"],
                Some(format!("{objects}\n").as_bytes()),
            )?,
        );
        let objects: BTreeSet<_> = objects.lines().collect();
        // Replacement refs for these objects change what the review sees.
        // Other new refs, including local-origin pushes, remain ordinary work.
        let replacements = repository::git(
            repo,
            &[
                "for-each-ref",
                "--format=%(refname:strip=2) %(objectname)",
                "refs/replace/",
            ],
        )?;
        for replacement in replacements.lines() {
            if replacement
                .split_once(' ')
                .is_some_and(|(id, _)| objects.contains(id))
            {
                hash(&mut digest, replacement.as_bytes());
            }
        }
        for name in &self.inputs {
            hash_input(&mut digest, repo, name)?;
        }
        Ok(digest.finalize().to_vec())
    }
}

/// Digest of one captured tree. Symlinks stay links, and a FIFO or device
/// does not block the read: a pass-through Trial can replace either after
/// the copy, outside the Trial's own cap.
pub(super) fn tree_digest(root: &Path) -> Result<Vec<u8>> {
    let mut files = BTreeMap::new();
    collect(root, "", &mut files)?;
    let mut digest = Sha256::new();
    for (path, bytes) in files {
        hash(&mut digest, path.as_bytes());
        hash(&mut digest, &bytes);
    }
    Ok(digest.finalize().to_vec())
}

fn collect(dir: &Path, prefix: &str, out: &mut BTreeMap<String, Vec<u8>>) -> Result<()> {
    let mut entries = Vec::new();
    for entry in fs::read_dir(dir)? {
        entries.push(entry?);
    }
    entries.sort_by_key(|entry| entry.file_name());
    for entry in entries {
        let name = entry.file_name();
        let name = name.to_string_lossy();
        let rel = if prefix.is_empty() {
            name.into_owned()
        } else {
            format!("{prefix}/{name}")
        };
        let path = entry.path();
        let metadata = fs::symlink_metadata(&path)?;
        let mut record = metadata.permissions().mode().to_le_bytes().to_vec();
        if metadata.is_dir() {
            record.extend_from_slice(b"dir");
            out.insert(rel.clone(), record);
            collect(&path, &rel, out)?;
        } else if metadata.is_symlink() {
            record.extend_from_slice(b"link");
            record.extend_from_slice(fs::read_link(&path)?.as_os_str().as_encoded_bytes());
            out.insert(rel, record);
        } else if metadata.is_file() {
            record.extend_from_slice(b"file");
            match read_regular(&path)? {
                Some(bytes) => record.extend(bytes),
                None => record.extend_from_slice(b"not-regular"),
            }
            out.insert(rel, record);
        } else {
            record.extend_from_slice(b"other");
            out.insert(rel, record);
        }
    }
    Ok(())
}

fn hash_input(digest: &mut Sha256, repo: &Path, name: &str) -> Result<()> {
    let path = repo.join(name);
    match fs::symlink_metadata(&path) {
        Ok(metadata) => {
            hash(digest, name.as_bytes());
            hash(digest, &metadata.permissions().mode().to_le_bytes());
            if metadata.is_symlink() {
                hash(digest, fs::read_link(&path)?.as_os_str().as_encoded_bytes());
            }
            // The implicit issue input may be a tracked symlink. Protect
            // the text the session reads as well as the link itself.
            // Follow only a regular file: a FIFO blocks, and a device such
            // as /dev/zero never ends. Either would hit this supervisor,
            // outside the Trial's memory cap and timeout.
            if metadata.is_file() || metadata.is_symlink() {
                match read_regular(&path)? {
                    Some(bytes) => hash(digest, &bytes),
                    None => hash(digest, b"not-regular"),
                }
            }
        }
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
            hash(digest, name.as_bytes());
            hash(digest, b"missing");
        }
        Err(error) => return Err(error.into()),
    }
    Ok(())
}

fn read_regular(path: &Path) -> Result<Option<Vec<u8>>> {
    let mut file = fs::OpenOptions::new()
        .read(true)
        .custom_flags(libc::O_NONBLOCK | libc::O_NOCTTY | libc::O_CLOEXEC)
        .open(path)?;
    if !file.metadata()?.file_type().is_file() {
        return Ok(None);
    }
    let mut bytes = Vec::new();
    file.read_to_end(&mut bytes)?;
    Ok(Some(bytes))
}

fn hash(digest: &mut Sha256, bytes: &[u8]) {
    digest.update((bytes.len() as u64).to_le_bytes());
    digest.update(bytes);
}