1use std::path::Path;
4use std::process::{Command, Stdio};
5
6use crate::format::{INPUTS_REF, NOTES_REF};
7use crate::git;
8
9#[derive(Clone, Copy, Debug, PartialEq)]
16pub enum Mirror {
17 Fresh,
19 NoOrigin,
21 Deleted,
23 Undeletable,
25 Unreachable,
27 TimedOut,
29 Unwritable,
32}
33
34impl Mirror {
35 pub fn as_str(self) -> &'static str {
36 match self {
37 Mirror::Fresh => "fetched",
38 Mirror::NoOrigin => "no-origin",
39 Mirror::Deleted => "absent",
40 Mirror::Undeletable => "undeletable",
41 Mirror::Unreachable | Mirror::TimedOut => "unreachable",
42 Mirror::Unwritable => "unwritable",
43 }
44 }
45 pub fn judged(self) -> bool {
46 matches!(self, Mirror::Fresh | Mirror::NoOrigin)
47 }
48}
49
50fn drop_mirror(root: &Path, loud: &mut Vec<String>, notes_ref: &str) -> Mirror {
52 let full = format!("refs/notes/{notes_ref}");
53 let Some(oid) = git::stdout_in(root, &["rev-parse", "--verify", "--quiet", &full]) else {
54 return Mirror::Deleted;
55 };
56 if git::succeeds_in(root, &["update-ref", "-d", &full, &oid]) {
57 loud.push(format!(
58 "origin has no {full}; deleted the local mirror (was {oid}); restore: git update-ref {full} {oid}"
59 ));
60 Mirror::Deleted
61 } else {
62 Mirror::Undeletable
63 }
64}
65
66pub(crate) const TMP_NS: &str = "refs/attest-tmp/";
70
71fn sweep_tmp_refs(root: &Path) {
76 let Some(refs) = git::stdout_in(root, &["for-each-ref", "--format=%(refname)", TMP_NS]) else {
77 return;
78 };
79 let me = std::process::id().to_string();
80 for r in refs.lines() {
81 let Some(pid) = r
82 .strip_prefix(TMP_NS)
83 .and_then(|rest| rest.split('/').next())
84 .filter(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
85 else {
86 continue;
87 };
88 if pid != me && process_gone(pid) {
89 let _ = git::succeeds_in(root, &["update-ref", "-d", r]);
90 }
91 }
92}
93
94fn process_gone(pid: &str) -> bool {
97 if !cfg!(unix) {
98 return false;
99 }
100 Command::new("kill")
101 .args(["-0", pid])
102 .env("LC_ALL", "C")
103 .stdin(Stdio::null())
104 .stdout(Stdio::null())
105 .stderr(Stdio::piped())
106 .output()
107 .is_ok_and(|o| {
108 !o.status.success()
109 && String::from_utf8_lossy(&o.stderr)
110 .to_lowercase()
111 .contains("no such process")
112 })
113}
114
115fn adopt(root: &Path, notes_ref: &str, tmp: &str, old: Option<&str>) -> Mirror {
121 let full = format!("refs/notes/{notes_ref}");
122 let Some(new) = git::stdout_in(root, &["rev-parse", "--verify", "--quiet", tmp]) else {
123 return Mirror::Unwritable;
124 };
125 if git::succeeds_in(root, &["update-ref", &full, &new, old.unwrap_or("")]) {
126 return Mirror::Fresh;
127 }
128 match git::stdout_in(root, &["rev-parse", "--verify", "--quiet", &full]) {
129 Some(now) if now == new => Mirror::Fresh,
130 _ => Mirror::Unwritable,
131 }
132}
133
134pub fn sync_mirrors(root: &Path, loud: &mut Vec<String>) -> (Mirror, Mirror) {
140 if !git::succeeds_in(root, &["remote", "get-url", "origin"]) {
141 return (Mirror::NoOrigin, Mirror::NoOrigin);
142 }
143 sweep_tmp_refs(root);
144 let pid = std::process::id();
145 let full = |r: &str| format!("refs/notes/{r}");
146 let tmp = |r: &str| format!("{TMP_NS}{pid}/{r}");
147 let spec = |r: &str| format!("+{}:{}", full(r), tmp(r));
148 for r in [NOTES_REF, INPUTS_REF] {
153 let _ = git::succeeds_in(root, &["update-ref", "-d", &tmp(r)]);
154 if let Some(lock) = git::stdout_in(
155 root,
156 &["rev-parse", "--git-path", &format!("{}.lock", tmp(r))],
157 ) {
158 let _ = std::fs::remove_file(root.join(lock));
160 }
161 }
162 let old_main = git::stdout_in(
163 root,
164 &["rev-parse", "--verify", "--quiet", &full(NOTES_REF)],
165 );
166 let old_inputs = git::stdout_in(
167 root,
168 &["rev-parse", "--verify", "--quiet", &full(INPUTS_REF)],
169 );
170 let adopt_both = || {
171 (
172 adopt(root, NOTES_REF, &tmp(NOTES_REF), old_main.as_deref()),
173 adopt(root, INPUTS_REF, &tmp(INPUTS_REF), old_inputs.as_deref()),
174 )
175 };
176 let states = match git::remote(
177 root,
178 &[
179 "fetch",
180 "--quiet",
181 "origin",
182 &spec(NOTES_REF),
183 &spec(INPUTS_REF),
184 ],
185 ) {
186 git::Remote::Done(0, _) => adopt_both(),
187 git::Remote::TimedOut => (Mirror::TimedOut, Mirror::TimedOut),
189 _ => match git::remote(
190 root,
191 &[
192 "ls-remote",
193 "--exit-code",
194 "origin",
195 &full(NOTES_REF),
196 &full(INPUTS_REF),
197 ],
198 ) {
199 git::Remote::Done(0, listing) => {
200 let listed = |r: &str| {
201 let f = full(r);
202 listing
203 .lines()
204 .any(|l| l.split_whitespace().nth(1) == Some(f.as_str()))
205 };
206 let (has_main, has_inputs) = (listed(NOTES_REF), listed(INPUTS_REF));
207 let mut want = Vec::new();
208 if has_main {
209 want.push(spec(NOTES_REF));
210 }
211 if has_inputs {
212 want.push(spec(INPUTS_REF));
213 }
214 let fetched = if want.is_empty() {
215 None
216 } else {
217 let mut args = vec!["fetch", "--quiet", "origin"];
218 args.extend(want.iter().map(String::as_str));
219 Some(git::remote(root, &args))
220 };
221 let one = |present: bool, r: &str, old: Option<&str>, loud: &mut Vec<String>| {
222 if !present {
223 return drop_mirror(root, loud, r);
224 }
225 match &fetched {
226 Some(git::Remote::Done(0, _)) => adopt(root, r, &tmp(r), old),
227 Some(git::Remote::TimedOut) => Mirror::TimedOut,
228 _ => Mirror::Unreachable,
229 }
230 };
231 let m = one(has_main, NOTES_REF, old_main.as_deref(), loud);
232 let i = one(has_inputs, INPUTS_REF, old_inputs.as_deref(), loud);
233 (m, i)
234 }
235 git::Remote::Done(2, _) => (
236 drop_mirror(root, loud, NOTES_REF),
237 drop_mirror(root, loud, INPUTS_REF),
238 ),
239 git::Remote::TimedOut => (Mirror::TimedOut, Mirror::TimedOut),
240 _ => (Mirror::Unreachable, Mirror::Unreachable),
241 },
242 };
243 for r in [NOTES_REF, INPUTS_REF] {
244 let _ = git::succeeds_in(root, &["update-ref", "-d", &tmp(r)]);
245 }
246 states
247}
248
249pub struct Publish<'a> {
256 pub remote: &'a str,
258 pub attempts: u32,
260 pub pause: bool,
263 pub identity: Option<(&'a str, &'a str)>,
266 pub push_env: &'a [(&'a str, &'a str)],
269}
270
271#[derive(Debug, Clone, PartialEq, Eq)]
273#[non_exhaustive]
274pub enum Published {
275 Pushed,
277 AlreadyPresent,
279 Local,
281 PushFailed,
283 Error,
285}
286
287#[derive(Debug, Clone, Copy, PartialEq, Eq)]
294#[non_exhaustive]
295pub enum PushFailure {
296 Raced,
297 Unreachable,
298 Refused,
299}
300
301pub fn classify_push(stderr: &str) -> PushFailure {
306 const RACED: [&str; 5] = [
307 "non-fast-forward",
308 "fetch first",
309 "stale info",
310 "cannot lock ref",
311 "failed to lock",
312 ];
313 const UNREACHABLE: [&str; 5] = [
314 "Could not resolve host",
315 "Connection refused",
316 "Connection timed out",
317 "unable to access",
318 "Could not read from remote repository",
319 ];
320 let own = |l: &&str| {
321 let l = l.trim_start();
322 l.starts_with("fatal:") || l.starts_with("ssh:") || l.starts_with("error:")
323 };
324 if RACED.iter().any(|m| stderr.contains(m)) {
325 PushFailure::Raced
326 } else if stderr
327 .lines()
328 .filter(own)
329 .any(|l| UNREACHABLE.iter().any(|m| l.contains(m)))
330 {
331 PushFailure::Unreachable
332 } else {
333 PushFailure::Refused
334 }
335}
336
337fn notes_git(root: &Path, identity: Option<(&str, &str)>) -> Command {
339 let mut cmd = Command::new("git");
340 cmd.current_dir(root).stdin(Stdio::null());
341 if let Some((name, email)) = identity {
342 cmd.args([
343 "-c",
344 &format!("user.name={name}"),
345 "-c",
346 &format!("user.email={email}"),
347 ]);
348 }
349 cmd
350}
351
352fn append(
356 root: &Path,
357 identity: Option<(&str, &str)>,
358 notes_ref: &str,
359 object: &str,
360 block: &str,
361) -> Option<bool> {
362 let existing =
363 git::stdout_in(root, &["notes", "--ref", notes_ref, "show", object]).unwrap_or_default();
364 if existing.contains(block.trim_end()) {
365 return Some(false);
366 }
367 notes_git(root, identity)
368 .args(["notes", "--ref", notes_ref, "append", "-m", block, object])
369 .stdout(Stdio::null())
370 .stderr(Stdio::null())
371 .status()
372 .ok()?
373 .success()
374 .then_some(true)
375}
376
377pub fn publish(
392 root: &Path,
393 how: &Publish,
394 notes_ref: &str,
395 items: &[(String, String)],
396 log: &mut Vec<String>,
397) -> Published {
398 let full = format!("refs/notes/{notes_ref}");
399 let tref = format!("attest-publish-{}-{notes_ref}", std::process::id());
400 let tfull = format!("refs/notes/{tref}");
401 let mut attempt = 0;
402 let outcome = loop {
403 attempt += 1;
404 let _ = git::succeeds_in(root, &["update-ref", "-d", &tfull]);
405 let _ = git::succeeds_in(
408 root,
409 &["fetch", "--quiet", how.remote, &format!("+{full}:{tfull}")],
410 );
411 let mut appended = false;
412 for (object, block) in items {
413 match append(root, how.identity, &tref, object, block) {
414 Some(true) => appended = true,
415 Some(false) => {}
416 None => return cleanup(root, &tfull, Published::Error),
417 }
418 }
419 if !appended {
420 break Published::AlreadyPresent;
421 }
422 let out = Command::new("git")
423 .current_dir(root)
424 .args(["push", "--quiet", how.remote, &format!("{tfull}:{full}")])
425 .envs(how.push_env.iter().copied())
426 .stdin(Stdio::null())
427 .stdout(Stdio::null())
428 .output();
429 let Ok(out) = out else {
430 log.push(format!("push of {full} could not run git"));
431 break Published::PushFailed;
432 };
433 if out.status.success() {
434 if how.remote == "origin" {
435 let _ = git::succeeds_in(root, &["update-ref", &full, &tfull]);
436 }
437 break Published::Pushed;
438 }
439 let why = scrub(&String::from_utf8_lossy(&out.stderr));
440 match classify_push(&why) {
441 PushFailure::Raced if attempt < how.attempts => {
442 log.push(format!(
443 "push of {full} rejected, another job attested this tree meanwhile; retrying"
444 ));
445 if how.pause {
446 std::thread::sleep(std::time::Duration::from_secs(
447 u64::from(attempt) + u64::from(std::process::id() % 3),
448 ));
449 }
450 }
451 PushFailure::Raced => {
452 log.push(format!("gave up on {full} after {attempt} attempts: {why}"));
453 break Published::PushFailed;
454 }
455 _ => {
456 log.push(format!("push of {full} refused: {why}"));
457 break Published::PushFailed;
458 }
459 }
460 };
461 cleanup(root, &tfull, outcome)
462}
463
464fn cleanup(root: &Path, tfull: &str, outcome: Published) -> Published {
465 let _ = git::succeeds_in(root, &["update-ref", "-d", tfull]);
466 outcome
467}
468
469pub(crate) fn scrub(stderr: &str) -> String {
471 let mut out = String::new();
472 for word in stderr.split_whitespace() {
473 if !out.is_empty() {
474 out.push(' ');
475 }
476 match word.find("://") {
477 Some(i) => {
478 let (scheme, rest) = word.split_at(i + 3);
479 let host_start = match rest.find('/') {
480 Some(slash) => rest[..slash].rfind('@').map(|a| a + 1).unwrap_or(0),
481 None => rest.rfind('@').map(|a| a + 1).unwrap_or(0),
482 };
483 out.push_str(scheme);
484 out.push_str(&rest[host_start..]);
485 }
486 None => out.push_str(word),
487 }
488 }
489 out
490}
491
492pub fn publish_local(
495 root: &Path,
496 identity: Option<(&str, &str)>,
497 notes_ref: &str,
498 items: &[(String, String)],
499) -> Published {
500 let mut appended = false;
501 for (object, block) in items {
502 match append(root, identity, notes_ref, object, block) {
503 Some(true) => appended = true,
504 Some(false) => {}
505 None => return Published::Error,
506 }
507 }
508 if appended {
509 Published::Local
510 } else {
511 Published::AlreadyPresent
512 }
513}