use std::io::{IsTerminal as _, Read as _};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use anyhow::{Context as _, Result, bail, ensure};
use openmoat_core::{DEFAULT_POLICY, Policy, RepoPolicy};
use super::gate::{self, Finding};
use super::parse::{self, FileChange};
use crate::context;
use crate::push::git::{Git, Unchecked};
pub const REPO_POLICY: &str = ".moat/policy.yaml";
const MAX_DIFF_BYTES: usize = 64 * 1024 * 1024;
const MAX_LIST_BYTES: usize = 16 * 1024 * 1024;
const MAX_POLICY_BYTES: usize = 1024 * 1024;
const BUDGET: Duration = Duration::from_secs(300);
pub struct Input {
pub files: Vec<FileChange>,
pub unchecked: Option<Finding>,
pub policy: Policy,
pub policy_files: Vec<String>,
pub policy_source: String,
pub repo_policy: Option<String>,
}
pub fn from_stdin(policy: Option<&Path>) -> Result<Input> {
let mut stdin = std::io::stdin().lock();
ensure!(
!stdin.is_terminal(),
"give --base (and --head), or pipe a unified diff on stdin"
);
let mut bytes = Vec::new();
(&mut stdin)
.take(MAX_DIFF_BYTES as u64 + 1)
.read_to_end(&mut bytes)
.context("reading the diff on stdin")?;
ensure!(
bytes.len() <= MAX_DIFF_BYTES,
"the diff on stdin is larger than {} MiB, more than OpenMoat reads",
MAX_DIFF_BYTES / (1024 * 1024)
);
let text = String::from_utf8_lossy(&bytes);
let files = parse::parse(&text).context("reading the diff on stdin")?;
let (user, source) = match policy {
Some(path) => (context::load_policy(path)?, path.display().to_string()),
None => default_policy()?,
};
let mut policy_files = vec![REPO_POLICY.to_owned()];
policy_files.extend(policy.and_then(relative));
Ok(Input {
files,
unchecked: None,
policy: user,
policy_files,
policy_source: source,
repo_policy: None,
})
}
pub fn from_git(base: &str, head: &str, policy: Option<&Path>) -> Result<Input> {
let program = git_program()?;
let deadline = (Instant::now() + BUDGET, BUDGET);
let here = Git::new(&program, ".", deadline.0, deadline.1);
let root = here
.value(&["rev-parse", "--show-toplevel"])
.map_err(failed)?
.context("not inside a git repository")?;
let git = Git::new(&program, &root, deadline.0, deadline.1);
let base = commit(&git, base)?;
let head = commit(&git, head)?;
let fork = git
.value(&["merge-base", &base, &head])
.map_err(failed)?
.with_context(|| {
format!(
"{base} and {head} have no common commit in this clone; fetch their history \
(`fetch-depth: 0` for actions/checkout)"
)
})?;
let (files, unchecked) = changes(&git, &fork, &head)?;
let (user, source, mut policy_files) = user_policy_at(&git, &base, policy)?;
policy_files.push(REPO_POLICY.to_owned());
let (policy, repo_policy) = match blob(&git, &base, REPO_POLICY)? {
Some(text) => {
let repo = RepoPolicy::parse(&text)
.with_context(|| format!("invalid {REPO_POLICY} at {}", short(&base)))?;
let merged = repo.merge(&user, false)?;
(merged, Some(format!("{REPO_POLICY} at {}", short(&base))))
}
None => (user, None),
};
Ok(Input {
files,
unchecked,
policy,
policy_files,
policy_source: source,
repo_policy,
})
}
fn user_policy_at(
git: &Git,
base: &str,
policy: Option<&Path>,
) -> Result<(Policy, String, Vec<String>)> {
let Some(path) = policy else {
let (user, source) = default_policy()?;
return Ok((user, source, Vec::new()));
};
let Some(rel) = relative(path) else {
let source = path.display().to_string();
return Ok((context::load_policy(path)?, source, Vec::new()));
};
let text = blob(git, base, &rel)?
.with_context(|| format!("{rel} does not exist at {}", short(base)))?;
let user = context::parse_policy(text.into_bytes(), path)?;
Ok((user, format!("{rel} at {}", short(base)), vec![rel]))
}
fn default_policy() -> Result<(Policy, String)> {
let policy = Policy::parse(DEFAULT_POLICY).context("the default policy")?;
Ok((policy, "the default policy".to_owned()))
}
fn relative(path: &Path) -> Option<String> {
let text = path.to_str()?.replace('\\', "/");
(!path.is_absolute() && !text.starts_with('/'))
.then(|| text.trim_start_matches("./").to_owned())
}
fn git_program() -> Result<PathBuf> {
let dirs: Vec<PathBuf> = std::env::var_os("PATH")
.map(|p| {
std::env::split_paths(&p)
.filter(|d| d.is_absolute())
.collect()
})
.unwrap_or_default();
crate::environment::find_in(&dirs, &[], "git").context("no git found on PATH")
}
fn failed(Unchecked(why): Unchecked) -> anyhow::Error {
anyhow::Error::msg(why)
}
fn commit(git: &Git, rev: &str) -> Result<String> {
if rev.is_empty() || rev.starts_with('-') {
bail!("`{rev}` is not a revision");
}
let spec = format!("{rev}^{{commit}}");
git.value(&["rev-parse", "--verify", "--quiet", &spec])
.map_err(failed)?
.with_context(|| format!("`{rev}` names no commit in this repository"))
}
fn short(hash: &str) -> &str {
hash.get(..12).unwrap_or(hash)
}
fn changes(git: &Git, fork: &str, head: &str) -> Result<(Vec<FileChange>, Option<Finding>)> {
let options = [
"--no-color",
"--no-ext-diff",
"--no-textconv",
"--no-renames",
"--ignore-submodules=none",
"--submodule=short",
"--full-index",
"--src-prefix=a/",
"--dst-prefix=b/",
"--no-relative",
];
let mut args = vec!["diff"];
args.extend(options);
args.extend(["--raw", "-z", fork, head]);
let raw = git.run(&args, MAX_LIST_BYTES).map_err(failed)?;
ensure!(!raw.cut, "the change lists more files than OpenMoat reads");
ensure!(raw.ok, "git could not list the changed files");
let listed = raw_entries(&String::from_utf8_lossy(&raw.bytes));
let mut args = vec!["diff"];
args.extend(options);
args.extend([fork, head]);
let patch = git.run(&args, MAX_DIFF_BYTES).map_err(failed)?;
let (mut files, unchecked) = if patch.cut {
let why = format!(
"the diff is larger than {} MiB, more than OpenMoat reads: its paths were checked, \
its added lines were not searched for tokens",
MAX_DIFF_BYTES / (1024 * 1024)
);
(Vec::new(), Some(gate::unchecked(why)))
} else {
ensure!(patch.ok, "git could not show the diff");
let text = String::from_utf8_lossy(&patch.bytes);
(parse::parse(&text).context("reading git's diff")?, None)
};
for entry in listed {
let named = entry.new.as_deref().or(entry.old.as_deref());
match files
.iter_mut()
.find(|f| f.paths().any(|p| Some(p) == named))
{
Some(file) => {
file.old_mode = entry.old_mode.clone().or(file.old_mode.take());
file.new_mode = entry.new_mode.clone().or(file.new_mode.take());
}
None => files.push(entry),
}
}
Ok((files, unchecked))
}
fn raw_entries(text: &str) -> Vec<FileChange> {
let mut entries = Vec::new();
let mut fields = text.split('\0');
while let (Some(meta), Some(path)) = (fields.next(), fields.next()) {
let mut meta = meta.trim_start_matches(':').split(' ');
let mode = |m: Option<&str>| m.filter(|m| *m != "000000").map(str::to_owned);
let (old_mode, new_mode) = (mode(meta.next()), mode(meta.next()));
let status = meta.nth(2).unwrap_or_default();
entries.push(FileChange {
old: (!status.starts_with('A')).then(|| path.to_owned()),
new: (!status.starts_with('D')).then(|| path.to_owned()),
old_mode,
new_mode,
..FileChange::default()
});
}
entries
}
fn blob(git: &Git, rev: &str, path: &str) -> Result<Option<String>> {
let listing = git
.run(
&["ls-tree", "-z", "--full-tree", rev, "--", path],
MAX_LIST_BYTES,
)
.map_err(failed)?;
ensure!(
listing.ok && !listing.cut,
"git could not list {path} at {}",
short(rev)
);
let listing = String::from_utf8_lossy(&listing.bytes);
let Some(entry) = listing.split('\0').find(|e| !e.is_empty()) else {
return Ok(None);
};
let mode = entry.split(' ').next().unwrap_or_default();
ensure!(
matches!(mode, "100644" | "100755"),
"{path} at {} is not a regular file (mode {mode})",
short(rev)
);
let spec = format!("{rev}:{path}");
let out = git
.run(&["cat-file", "blob", &spec], MAX_POLICY_BYTES)
.map_err(failed)?;
ensure!(
out.ok && !out.cut,
"git could not read {path} at {} (at most {MAX_POLICY_BYTES} bytes)",
short(rev)
);
String::from_utf8(out.bytes)
.map(Some)
.with_context(|| format!("{path} at {} is not UTF-8", short(rev)))
}