use std::path::Path;
use std::process::{Command, Stdio};
use crate::format::{INPUTS_REF, NOTES_REF};
use crate::git;
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Mirror {
Fresh,
NoOrigin,
Deleted,
Undeletable,
Unreachable,
TimedOut,
Unwritable,
}
impl Mirror {
pub fn as_str(self) -> &'static str {
match self {
Mirror::Fresh => "fetched",
Mirror::NoOrigin => "no-origin",
Mirror::Deleted => "absent",
Mirror::Undeletable => "undeletable",
Mirror::Unreachable | Mirror::TimedOut => "unreachable",
Mirror::Unwritable => "unwritable",
}
}
pub fn judged(self) -> bool {
matches!(self, Mirror::Fresh | Mirror::NoOrigin)
}
}
fn drop_mirror(root: &Path, loud: &mut Vec<String>, notes_ref: &str) -> Mirror {
let full = format!("refs/notes/{notes_ref}");
let Some(oid) = git::stdout_in(root, &["rev-parse", "--verify", "--quiet", &full]) else {
return Mirror::Deleted;
};
if git::succeeds_in(root, &["update-ref", "-d", &full, &oid]) {
loud.push(format!(
"origin has no {full}; deleted the local mirror (was {oid}); restore: git update-ref {full} {oid}"
));
Mirror::Deleted
} else {
Mirror::Undeletable
}
}
pub(crate) const TMP_NS: &str = "refs/attest-tmp/";
fn sweep_tmp_refs(root: &Path) {
let Some(refs) = git::stdout_in(root, &["for-each-ref", "--format=%(refname)", TMP_NS]) else {
return;
};
let me = std::process::id().to_string();
for r in refs.lines() {
let Some(pid) = r
.strip_prefix(TMP_NS)
.and_then(|rest| rest.split('/').next())
.filter(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
else {
continue;
};
if pid != me && process_gone(pid) {
let _ = git::succeeds_in(root, &["update-ref", "-d", r]);
}
}
}
fn process_gone(pid: &str) -> bool {
if !cfg!(unix) {
return false;
}
Command::new("kill")
.args(["-0", pid])
.env("LC_ALL", "C")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.output()
.is_ok_and(|o| {
!o.status.success()
&& String::from_utf8_lossy(&o.stderr)
.to_lowercase()
.contains("no such process")
})
}
fn adopt(root: &Path, notes_ref: &str, tmp: &str, old: Option<&str>) -> Mirror {
let full = format!("refs/notes/{notes_ref}");
let Some(new) = git::stdout_in(root, &["rev-parse", "--verify", "--quiet", tmp]) else {
return Mirror::Unwritable;
};
if git::succeeds_in(root, &["update-ref", &full, &new, old.unwrap_or("")]) {
return Mirror::Fresh;
}
match git::stdout_in(root, &["rev-parse", "--verify", "--quiet", &full]) {
Some(now) if now == new => Mirror::Fresh,
_ => Mirror::Unwritable,
}
}
pub fn sync_mirrors(root: &Path, loud: &mut Vec<String>) -> (Mirror, Mirror) {
if !git::succeeds_in(root, &["remote", "get-url", "origin"]) {
return (Mirror::NoOrigin, Mirror::NoOrigin);
}
sweep_tmp_refs(root);
let pid = std::process::id();
let full = |r: &str| format!("refs/notes/{r}");
let tmp = |r: &str| format!("{TMP_NS}{pid}/{r}");
let spec = |r: &str| format!("+{}:{}", full(r), tmp(r));
for r in [NOTES_REF, INPUTS_REF] {
let _ = git::succeeds_in(root, &["update-ref", "-d", &tmp(r)]);
if let Some(lock) = git::stdout_in(
root,
&["rev-parse", "--git-path", &format!("{}.lock", tmp(r))],
) {
let _ = std::fs::remove_file(root.join(lock));
}
}
let old_main = git::stdout_in(
root,
&["rev-parse", "--verify", "--quiet", &full(NOTES_REF)],
);
let old_inputs = git::stdout_in(
root,
&["rev-parse", "--verify", "--quiet", &full(INPUTS_REF)],
);
let adopt_both = || {
(
adopt(root, NOTES_REF, &tmp(NOTES_REF), old_main.as_deref()),
adopt(root, INPUTS_REF, &tmp(INPUTS_REF), old_inputs.as_deref()),
)
};
let states = match git::remote(
root,
&[
"fetch",
"--quiet",
"origin",
&spec(NOTES_REF),
&spec(INPUTS_REF),
],
) {
git::Remote::Done(0, _) => adopt_both(),
git::Remote::TimedOut => (Mirror::TimedOut, Mirror::TimedOut),
_ => match git::remote(
root,
&[
"ls-remote",
"--exit-code",
"origin",
&full(NOTES_REF),
&full(INPUTS_REF),
],
) {
git::Remote::Done(0, listing) => {
let listed = |r: &str| {
let f = full(r);
listing
.lines()
.any(|l| l.split_whitespace().nth(1) == Some(f.as_str()))
};
let (has_main, has_inputs) = (listed(NOTES_REF), listed(INPUTS_REF));
let mut want = Vec::new();
if has_main {
want.push(spec(NOTES_REF));
}
if has_inputs {
want.push(spec(INPUTS_REF));
}
let fetched = if want.is_empty() {
None
} else {
let mut args = vec!["fetch", "--quiet", "origin"];
args.extend(want.iter().map(String::as_str));
Some(git::remote(root, &args))
};
let one = |present: bool, r: &str, old: Option<&str>, loud: &mut Vec<String>| {
if !present {
return drop_mirror(root, loud, r);
}
match &fetched {
Some(git::Remote::Done(0, _)) => adopt(root, r, &tmp(r), old),
Some(git::Remote::TimedOut) => Mirror::TimedOut,
_ => Mirror::Unreachable,
}
};
let m = one(has_main, NOTES_REF, old_main.as_deref(), loud);
let i = one(has_inputs, INPUTS_REF, old_inputs.as_deref(), loud);
(m, i)
}
git::Remote::Done(2, _) => (
drop_mirror(root, loud, NOTES_REF),
drop_mirror(root, loud, INPUTS_REF),
),
git::Remote::TimedOut => (Mirror::TimedOut, Mirror::TimedOut),
_ => (Mirror::Unreachable, Mirror::Unreachable),
},
};
for r in [NOTES_REF, INPUTS_REF] {
let _ = git::succeeds_in(root, &["update-ref", "-d", &tmp(r)]);
}
states
}
pub struct Publish<'a> {
pub remote: &'a str,
pub attempts: u32,
pub pause: bool,
pub identity: Option<(&'a str, &'a str)>,
pub push_env: &'a [(&'a str, &'a str)],
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Published {
Pushed,
AlreadyPresent,
Local,
PushFailed,
Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum PushFailure {
Raced,
Unreachable,
Refused,
}
pub fn classify_push(stderr: &str) -> PushFailure {
const RACED: [&str; 5] = [
"non-fast-forward",
"fetch first",
"stale info",
"cannot lock ref",
"failed to lock",
];
const UNREACHABLE: [&str; 5] = [
"Could not resolve host",
"Connection refused",
"Connection timed out",
"unable to access",
"Could not read from remote repository",
];
let own = |l: &&str| {
let l = l.trim_start();
l.starts_with("fatal:") || l.starts_with("ssh:") || l.starts_with("error:")
};
if RACED.iter().any(|m| stderr.contains(m)) {
PushFailure::Raced
} else if stderr
.lines()
.filter(own)
.any(|l| UNREACHABLE.iter().any(|m| l.contains(m)))
{
PushFailure::Unreachable
} else {
PushFailure::Refused
}
}
fn notes_git(root: &Path, identity: Option<(&str, &str)>) -> Command {
let mut cmd = Command::new("git");
cmd.current_dir(root).stdin(Stdio::null());
if let Some((name, email)) = identity {
cmd.args([
"-c",
&format!("user.name={name}"),
"-c",
&format!("user.email={email}"),
]);
}
cmd
}
fn append(
root: &Path,
identity: Option<(&str, &str)>,
notes_ref: &str,
object: &str,
block: &str,
) -> Option<bool> {
let existing =
git::stdout_in(root, &["notes", "--ref", notes_ref, "show", object]).unwrap_or_default();
if existing.contains(block.trim_end()) {
return Some(false);
}
notes_git(root, identity)
.args(["notes", "--ref", notes_ref, "append", "-m", block, object])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.ok()?
.success()
.then_some(true)
}
pub fn publish(
root: &Path,
how: &Publish,
notes_ref: &str,
items: &[(String, String)],
log: &mut Vec<String>,
) -> Published {
let full = format!("refs/notes/{notes_ref}");
let tref = format!("attest-publish-{}-{notes_ref}", std::process::id());
let tfull = format!("refs/notes/{tref}");
let mut attempt = 0;
let outcome = loop {
attempt += 1;
let _ = git::succeeds_in(root, &["update-ref", "-d", &tfull]);
let _ = git::succeeds_in(
root,
&["fetch", "--quiet", how.remote, &format!("+{full}:{tfull}")],
);
let mut appended = false;
for (object, block) in items {
match append(root, how.identity, &tref, object, block) {
Some(true) => appended = true,
Some(false) => {}
None => return cleanup(root, &tfull, Published::Error),
}
}
if !appended {
break Published::AlreadyPresent;
}
let out = Command::new("git")
.current_dir(root)
.args(["push", "--quiet", how.remote, &format!("{tfull}:{full}")])
.envs(how.push_env.iter().copied())
.stdin(Stdio::null())
.stdout(Stdio::null())
.output();
let Ok(out) = out else {
log.push(format!("push of {full} could not run git"));
break Published::PushFailed;
};
if out.status.success() {
if how.remote == "origin" {
let _ = git::succeeds_in(root, &["update-ref", &full, &tfull]);
}
break Published::Pushed;
}
let why = scrub(&String::from_utf8_lossy(&out.stderr));
match classify_push(&why) {
PushFailure::Raced if attempt < how.attempts => {
log.push(format!(
"push of {full} rejected, another job attested this tree meanwhile; retrying"
));
if how.pause {
std::thread::sleep(std::time::Duration::from_secs(
u64::from(attempt) + u64::from(std::process::id() % 3),
));
}
}
PushFailure::Raced => {
log.push(format!("gave up on {full} after {attempt} attempts: {why}"));
break Published::PushFailed;
}
_ => {
log.push(format!("push of {full} refused: {why}"));
break Published::PushFailed;
}
}
};
cleanup(root, &tfull, outcome)
}
fn cleanup(root: &Path, tfull: &str, outcome: Published) -> Published {
let _ = git::succeeds_in(root, &["update-ref", "-d", tfull]);
outcome
}
pub(crate) fn scrub(stderr: &str) -> String {
let mut out = String::new();
for word in stderr.split_whitespace() {
if !out.is_empty() {
out.push(' ');
}
match word.find("://") {
Some(i) => {
let (scheme, rest) = word.split_at(i + 3);
let host_start = match rest.find('/') {
Some(slash) => rest[..slash].rfind('@').map(|a| a + 1).unwrap_or(0),
None => rest.rfind('@').map(|a| a + 1).unwrap_or(0),
};
out.push_str(scheme);
out.push_str(&rest[host_start..]);
}
None => out.push_str(word),
}
}
out
}
pub fn publish_local(
root: &Path,
identity: Option<(&str, &str)>,
notes_ref: &str,
items: &[(String, String)],
) -> Published {
let mut appended = false;
for (object, block) in items {
match append(root, identity, notes_ref, object, block) {
Some(true) => appended = true,
Some(false) => {}
None => return Published::Error,
}
}
if appended {
Published::Local
} else {
Published::AlreadyPresent
}
}