1use std::collections::{BTreeMap, BTreeSet, HashSet, VecDeque};
8use std::fs;
9use std::io::{Read, Write};
10use std::path::{Component, Path, PathBuf};
11use std::thread;
12use std::time::{Duration, SystemTime, UNIX_EPOCH};
13
14use serde_json::{Map, Value};
15
16use crate::api::{self, is_runs_file, job_file_run_id};
17use crate::workflow::{scan_yaml, RemoteWorkflow, ReusableSource};
18
19const ACCEPT_JSON: &str = "application/vnd.github+json";
20const ACCEPT_RAW: &str = "application/vnd.github.raw";
21const MAX_BODY: u64 = 32 * 1024 * 1024;
22const MAX_PAGES: usize = 10_000;
23const ATTEMPTS: u32 = 5;
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct FetchError {
28 message: String,
29 code: u8,
30}
31
32impl FetchError {
33 pub fn code(&self) -> u8 {
34 self.code
35 }
36
37 fn usage(message: impl Into<String>) -> Self {
38 Self {
39 message: message.into(),
40 code: 3,
41 }
42 }
43
44 fn input(message: impl Into<String>) -> Self {
45 Self {
46 message: message.into(),
47 code: 2,
48 }
49 }
50}
51
52impl std::fmt::Display for FetchError {
53 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54 f.write_str(&self.message)
55 }
56}
57
58impl std::error::Error for FetchError {}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub struct UtcDate {
63 year: i32,
64 month: u32,
65 day: u32,
66}
67
68impl UtcDate {
69 fn unix(self) -> u64 {
70 days_from_civil(self.year, self.month, self.day) as u64 * 86400
71 }
72
73 fn next_unix(self) -> u64 {
75 self.unix().saturating_add(86400)
76 }
77
78 fn text(self) -> String {
79 format!("{:04}-{:02}-{:02}", self.year, self.month, self.day)
80 }
81
82 fn from_unix(unix: u64) -> Self {
83 let (year, month, day) = civil_from_days((unix / 86400) as i64);
84 Self { year, month, day }
85 }
86}
87
88pub fn parse_repo_arg(text: &str) -> Result<String, String> {
90 let (owner, repo) = split_repo(text)?;
91 Ok(format!("{owner}/{repo}"))
92}
93
94pub fn parse_utc_date(text: &str) -> Result<UtcDate, String> {
96 parse_date(text).ok_or_else(|| "expected a UTC date YYYY-MM-DD".to_string())
97}
98
99pub fn fetch(
103 repo: &str,
104 out: &Path,
105 since: UtcDate,
106 until: Option<UtcDate>,
107) -> Result<String, FetchError> {
108 let (owner, name) = split_repo(repo).map_err(FetchError::usage)?;
109 let until = until.unwrap_or_else(today_utc);
110 if since.unix() > until.unix() {
111 return Err(FetchError::usage("--until is before --since"));
112 }
113 let secrets = secrets_from_env()?;
114 let base = api_base().map_err(|message| redact_error(message, &secrets.needles))?;
115 if out.exists() && !out.is_dir() {
116 return Err(path_error(out, "cannot read directory"));
117 }
118 let cache = if out.is_dir() {
119 load_cache(out)?
120 } else {
121 Cache::default()
122 };
123 let client = Client::open(&base, &owner, &name, &secrets)?;
124 let window = Window {
125 start: since.unix(),
126 end: until.next_unix(),
127 };
128 let repo_body = client.get_json(&client.repo_url(), "repository not found")?;
129 let default_branch = default_branch(&repo_body)?;
130 let run_pages = client.fetch_run_pages(window)?;
131 let incoming = incoming_runs(&run_pages)?;
132 let merged = merge_runs(&cache, &incoming);
133 let job_pages = client.fetch_needed_jobs(&cache, &merged.jobs)?;
134 let files = client.workflow_files(out, &merged.discovered, &default_branch)?;
135 commit(
136 out,
137 &repo_body,
138 &RunWrite {
139 cache: &cache,
140 pages: &run_pages,
141 merged: &merged.runs,
142 rewrite_runs: merged.rewrite_runs,
143 },
144 &job_pages,
145 &files,
146 )?;
147 let jobs = job_pages.iter().map(|page| page.jobs).sum::<usize>();
148 Ok(format!(
149 "{owner}/{name} {} .. {}\nruns {}\njobs {jobs}\nworkflow files {}\n",
150 since.text(),
151 until.text(),
152 merged.run_count,
153 files.len()
154 ))
155}
156
157fn redact_error(error: FetchError, needles: &[String]) -> FetchError {
158 FetchError {
159 message: redact_all_str(&error.message, needles),
160 code: error.code,
161 }
162}
163
164fn today_utc() -> UtcDate {
165 let secs = SystemTime::now()
166 .duration_since(UNIX_EPOCH)
167 .unwrap_or_default()
168 .as_secs();
169 UtcDate::from_unix(secs)
170}
171
172struct Secrets {
173 auth: String,
175 needles: Vec<String>,
177}
178
179fn secrets_from_env() -> Result<Secrets, FetchError> {
180 let mut auth = None;
181 let mut needles = Vec::new();
182 for name in ["GH_TOKEN", "GITHUB_TOKEN"] {
183 let Ok(value) = std::env::var(name) else {
184 continue;
185 };
186 let token = value.trim();
187 if token.is_empty() {
188 continue;
189 }
190 if token
191 .bytes()
192 .any(|byte| byte.is_ascii_whitespace() || byte.is_ascii_control())
193 {
194 return Err(FetchError::usage("set GH_TOKEN or GITHUB_TOKEN"));
195 }
196 if auth.is_none() {
197 auth = Some(token.to_string());
198 }
199 if !needles.iter().any(|item| item == token) {
200 needles.push(token.to_string());
201 }
202 }
203 let Some(auth) = auth else {
204 return Err(FetchError::usage("set GH_TOKEN or GITHUB_TOKEN"));
205 };
206 needles.sort_by_key(|item| std::cmp::Reverse(item.len()));
207 Ok(Secrets { auth, needles })
208}
209
210fn api_base() -> Result<String, FetchError> {
211 match std::env::var("GITHUB_API_URL") {
212 Ok(value) => {
213 let base = value.trim().trim_end_matches('/').to_string();
214 if origin_of(&base).is_some()
215 && (base.starts_with("https://") || base.starts_with("http://"))
216 {
217 Ok(base)
218 } else {
219 Err(FetchError::usage(
220 "GITHUB_API_URL must be an http or https URL",
221 ))
222 }
223 }
224 Err(_) => Ok("https://api.github.com".to_string()),
225 }
226}
227
228fn split_repo(text: &str) -> Result<(String, String), String> {
229 let text = text.trim();
230 let Some((owner, repo)) = text.split_once('/') else {
231 return Err("expected OWNER/REPO".to_string());
232 };
233 if owner.contains('/') || repo.contains('/') || !valid_name(owner) || !valid_name(repo) {
234 return Err("expected OWNER/REPO".to_string());
235 }
236 Ok((owner.to_string(), repo.to_string()))
237}
238
239fn valid_name(name: &str) -> bool {
240 let mut chars = name.chars();
241 let Some(first) = chars.next() else {
242 return false;
243 };
244 if !first.is_ascii_alphanumeric() {
245 return false;
246 }
247 let mut last = first;
248 for character in chars {
249 if !(character.is_ascii_alphanumeric()
250 || character == '.'
251 || character == '_'
252 || character == '-')
253 {
254 return false;
255 }
256 last = character;
257 }
258 last.is_ascii_alphanumeric()
259}
260
261fn parse_date(text: &str) -> Option<UtcDate> {
262 let text = text.trim();
263 if text.len() != 10 {
264 return None;
265 }
266 let bytes = text.as_bytes();
267 if bytes[4] != b'-' || bytes[7] != b'-' {
268 return None;
269 }
270 let year = i32::try_from(parse_digits(&bytes[0..4])?).ok()?;
271 let month = parse_digits(&bytes[5..7])?;
272 let day = parse_digits(&bytes[8..10])?;
273 if !valid_ymd(year, month, day) {
274 return None;
275 }
276 Some(UtcDate { year, month, day })
277}
278
279fn parse_digits(bytes: &[u8]) -> Option<u32> {
280 let mut value = 0u32;
281 for byte in bytes {
282 let digit = byte.wrapping_sub(b'0');
283 if digit > 9 {
284 return None;
285 }
286 value = value * 10 + u32::from(digit);
287 }
288 Some(value)
289}
290
291fn valid_ymd(year: i32, month: u32, day: u32) -> bool {
292 if !(1970..=9999).contains(&year) || day == 0 {
293 return false;
294 }
295 let max = match month {
296 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
297 4 | 6 | 9 | 11 => 30,
298 2 if leap(year) => 29,
299 2 => 28,
300 _ => return false,
301 };
302 day <= max
303}
304
305fn leap(year: i32) -> bool {
306 year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
307}
308
309#[derive(Clone, Copy, Debug, PartialEq, Eq)]
310struct Window {
311 start: u64,
313 end: u64,
315}
316
317impl Window {
318 fn split(self) -> Option<(Window, Window)> {
320 let span = self.end.saturating_sub(self.start);
321 if span <= 1 {
322 return None;
323 }
324 let mid = self.start + span / 2;
325 if mid <= self.start || mid >= self.end {
326 return None;
327 }
328 Some((
329 Window {
330 start: self.start,
331 end: mid,
332 },
333 Window {
334 start: mid,
335 end: self.end,
336 },
337 ))
338 }
339
340 fn created_param(self) -> String {
341 if self.start % 86400 == 0 && self.end % 86400 == 0 && self.end > self.start {
342 let start_day = format_day(self.start);
343 let last_day = format_day(self.end - 86400);
344 format!("{start_day}..{last_day}")
345 } else {
346 let start = format_rfc3339(self.start);
347 let last = format_rfc3339(self.end.saturating_sub(1));
348 format!("{start}..{last}")
349 }
350 }
351}
352
353fn format_day(unix: u64) -> String {
354 UtcDate::from_unix(unix).text()
355}
356
357fn format_rfc3339(unix: u64) -> String {
358 let day = format_day(unix);
359 let secs = unix % 86400;
360 let hour = secs / 3600;
361 let minute = (secs % 3600) / 60;
362 let second = secs % 60;
363 format!("{day}T{hour:02}:{minute:02}:{second:02}Z")
364}
365
366struct Client {
367 agent: ureq::Agent,
368 base: String,
369 owner: String,
370 repo: String,
371 token: String,
372 secrets: Vec<String>,
373}
374
375impl Client {
376 fn open(base: &str, owner: &str, repo: &str, secrets: &Secrets) -> Result<Self, FetchError> {
377 let config = ureq::Agent::config_builder()
378 .timeout_global(Some(Duration::from_secs(60)))
379 .http_status_as_error(false)
380 .https_only(false)
381 .max_redirects(0)
382 .build();
383 let agent: ureq::Agent = config.into();
384 Ok(Self {
385 agent,
386 base: base.to_string(),
387 owner: owner.to_string(),
388 repo: repo.to_string(),
389 token: secrets.auth.clone(),
390 secrets: secrets.needles.clone(),
391 })
392 }
393
394 fn redact_owned(&self, bytes: &[u8]) -> Vec<u8> {
395 let mut current = bytes.to_vec();
396 for secret in &self.secrets {
397 current = redact_bytes(¤t, secret);
398 }
399 current
400 }
401
402 fn redact_text(&self, text: &str) -> String {
403 String::from_utf8(self.redact_owned(text.as_bytes()))
404 .unwrap_or_else(|_| "invalid response".to_string())
405 }
406
407 fn repo_url(&self) -> String {
408 format!(
409 "{}/repos/{}/{}",
410 self.base,
411 encode_component(&self.owner),
412 encode_component(&self.repo)
413 )
414 }
415
416 fn runs_url(&self, window: Window) -> String {
417 format!(
418 "{}/repos/{}/{}/actions/runs?per_page=100&created={}",
419 self.base,
420 encode_component(&self.owner),
421 encode_component(&self.repo),
422 encode_component(&window.created_param())
423 )
424 }
425
426 fn jobs_url(&self, id: u64) -> String {
427 format!(
428 "{}/repos/{}/{}/actions/runs/{id}/jobs?filter=all&per_page=100",
429 self.base,
430 encode_component(&self.owner),
431 encode_component(&self.repo)
432 )
433 }
434
435 fn contents_url(&self, remote: &RemoteWorkflow) -> String {
436 format!(
437 "{}/repos/{}/{}/contents/{}?ref={}",
438 self.base,
439 encode_component(&remote.owner),
440 encode_component(&remote.repo),
441 encode_path(&remote.workflow_path),
442 encode_component(&remote.reference)
443 )
444 }
445
446 fn get_json(&self, url: &str, missing: &str) -> Result<Vec<u8>, FetchError> {
447 let wire = self.get(url, ACCEPT_JSON)?;
448 if wire.status == 200 {
449 return Ok(wire.body);
450 }
451 if wire.status == 404 {
452 return Err(FetchError::input(missing));
453 }
454 Err(FetchError::input(format!(
455 "the GitHub API returned HTTP {}",
456 wire.status
457 )))
458 }
459
460 fn get_file(&self, url: &str) -> Result<Option<Vec<u8>>, FetchError> {
461 let wire = self.get(url, ACCEPT_RAW)?;
462 if wire.status == 404 {
463 return Ok(None);
464 }
465 if wire.status != 200 {
466 return Err(FetchError::input(format!(
467 "the GitHub API returned HTTP {}",
468 wire.status
469 )));
470 }
471 self.decode_contents(&wire)
472 }
473
474 fn decode_contents(&self, wire: &Wire) -> Result<Option<Vec<u8>>, FetchError> {
475 match contents_bytes(&wire.content_type, &wire.body)? {
476 Contents::Bytes(bytes) => Ok(Some(self.redact_owned(&bytes))),
477 Contents::Download(url) => {
478 let next = self.get(&url, ACCEPT_RAW)?;
479 if next.status == 404 {
480 return Ok(None);
481 }
482 if next.status != 200 {
483 return Err(FetchError::input(format!(
484 "the GitHub API returned HTTP {}",
485 next.status
486 )));
487 }
488 match contents_bytes(&next.content_type, &next.body)? {
489 Contents::Bytes(bytes) => Ok(Some(self.redact_owned(&bytes))),
490 Contents::Download(_) => Err(invalid_contents()),
491 }
492 }
493 }
494 }
495
496 fn fetch_run_pages(&self, window: Window) -> Result<Vec<RunPage>, FetchError> {
497 let mut seen = HashSet::new();
498 self.fetch_window(window, &mut seen)
499 }
500
501 fn fetch_window(
502 &self,
503 window: Window,
504 seen: &mut HashSet<(u64, u64)>,
505 ) -> Result<Vec<RunPage>, FetchError> {
506 if window.end <= window.start || !seen.insert((window.start, window.end)) {
507 return Ok(Vec::new());
508 }
509 let url = self.runs_url(window);
510 let first = self.get_json_wire(&url)?;
511 let page = parse_runs_page(&first, &url)?;
512 if page_too_wide(&page) {
513 if let Some((left, right)) = window.split() {
514 let mut pages = self.fetch_window(left, seen)?;
515 pages.extend(self.fetch_window(right, seen)?);
516 return Ok(pages);
517 }
518 }
519 let mut pages = Vec::new();
520 let mut count = page.runs.len();
521 let mut next = page.next.clone();
522 let mut seen_urls = HashSet::new();
523 seen_urls.insert(url);
524 pages.push(page);
525 while let Some(url) = next {
526 if pages.len() >= MAX_PAGES || !seen_urls.insert(url.clone()) {
527 break;
528 }
529 let wire = self.get_json_wire(&url)?;
530 let page = parse_runs_page(&wire, &url)?;
531 count += page.runs.len();
532 if count >= 1000 || page.total.unwrap_or(0) >= 1000 {
533 if let Some((left, right)) = window.split() {
534 let mut split_pages = self.fetch_window(left, seen)?;
535 split_pages.extend(self.fetch_window(right, seen)?);
536 return Ok(split_pages);
537 }
538 }
539 next = page.next.clone();
540 pages.push(page);
541 }
542 Ok(pages)
543 }
544
545 fn get_json_wire(&self, url: &str) -> Result<Wire, FetchError> {
546 let wire = self.get(url, ACCEPT_JSON)?;
547 if wire.status == 200 {
548 return Ok(wire);
549 }
550 Err(FetchError::input(format!(
551 "the GitHub API returned HTTP {}",
552 wire.status
553 )))
554 }
555
556 fn fetch_needed_jobs(
557 &self,
558 cache: &Cache,
559 jobs: &[(u64, u64)],
560 ) -> Result<Vec<JobBatch>, FetchError> {
561 let mut batches = Vec::new();
562 for &(id, attempt) in jobs {
563 let stored = cache
564 .runs
565 .get(&id)
566 .map(|run| run.attempt)
567 .or_else(|| cache.job_attempt.get(&id).copied());
568 if !should_fetch_jobs(stored, cache.has_jobs.contains(&id), attempt) {
569 continue;
570 }
571 batches.push(self.fetch_job_batch(id)?);
572 }
573 Ok(batches)
574 }
575
576 fn fetch_job_batch(&self, id: u64) -> Result<JobBatch, FetchError> {
577 let mut url = self.jobs_url(id);
578 let mut seen = HashSet::new();
579 let mut bodies = Vec::new();
580 let mut jobs = 0usize;
581 loop {
582 if bodies.len() >= MAX_PAGES || !seen.insert(url.clone()) {
583 break;
584 }
585 let wire = self.get_json_wire(&url)?;
586 let page = parse_jobs_page(&wire, id, &url)?;
587 jobs += page.jobs;
588 let next = page.next;
589 bodies.push(wire.body);
590 let Some(next_url) = next else {
591 break;
592 };
593 url = next_url;
594 }
595 Ok(JobBatch { id, bodies, jobs })
596 }
597
598 fn workflow_files(
599 &self,
600 out: &Path,
601 runs: &[Value],
602 default_branch: &str,
603 ) -> Result<Vec<(PathBuf, Vec<u8>)>, FetchError> {
604 let mut queue = VecDeque::new();
605 let mut seen = HashSet::new();
606 for run in runs {
607 if let Some(remote) = remote_from_run(run, &self.owner, &self.repo, default_branch) {
608 enqueue(&mut queue, &mut seen, remote);
609 }
610 }
611 let mut files = Vec::new();
612 while let Some(remote) = queue.pop_front() {
613 let Some(bytes) = self.get_file(&self.contents_url(&remote))? else {
614 continue;
615 };
616 if let Some(path) = safe_cache_path(out, &remote) {
617 files.push((path, bytes.clone()));
618 }
619 if let Ok(text) = std::str::from_utf8(&bytes) {
620 if let Ok(workflow) = scan_yaml(Path::new("workflow.yml"), text) {
621 for job in workflow.jobs {
622 let Some(call) = job.uses else {
623 continue;
624 };
625 match call.source {
626 ReusableSource::Remote(callee) => {
627 enqueue(&mut queue, &mut seen, callee);
628 }
629 ReusableSource::Local => {
630 enqueue(
631 &mut queue,
632 &mut seen,
633 RemoteWorkflow {
634 owner: remote.owner.clone(),
635 repo: remote.repo.clone(),
636 workflow_path: format!(
637 ".github/workflows/{}",
638 call.file_name
639 ),
640 reference: remote.reference.clone(),
641 },
642 );
643 }
644 }
645 }
646 }
647 }
648 }
649 Ok(files)
650 }
651
652 fn get(&self, url: &str, accept: &str) -> Result<Wire, FetchError> {
653 let mut current = url.to_string();
654 let mut send_auth = same_origin(&self.base, ¤t);
655 let mut seen = HashSet::new();
656 for _ in 0..ATTEMPTS {
657 if !seen.insert(current.clone()) {
658 return Err(FetchError::input("the GitHub API returned HTTP 302"));
659 }
660 let wire = self.request(¤t, accept, send_auth)?;
661 if matches!(wire.status, 301 | 302 | 303 | 307 | 308) {
662 let loc = wire.location.clone().ok_or_else(|| {
663 FetchError::input(format!("the GitHub API returned HTTP {}", wire.status))
664 })?;
665 let next = resolve_url(&wire.url, &loc);
666 if !(next.starts_with("http://") || next.starts_with("https://")) {
667 return Err(FetchError::input(format!(
668 "the GitHub API returned HTTP {}",
669 wire.status
670 )));
671 }
672 send_auth = same_origin(&self.base, &next);
673 current = next;
674 continue;
675 }
676 return Ok(wire);
677 }
678 Err(FetchError::input("the GitHub API returned HTTP 302"))
679 }
680
681 fn request(&self, url: &str, accept: &str, send_auth: bool) -> Result<Wire, FetchError> {
682 let secrets = self.secrets.clone();
683 retry(
684 || self.request_once(url, accept, send_auth),
685 thread::sleep,
686 SystemTime::now,
687 &secrets,
688 )
689 }
690
691 fn request_once(&self, url: &str, accept: &str, send_auth: bool) -> Result<Wire, FetchError> {
692 let auth = format!("Bearer {}", self.token);
693 let mut call = self
694 .agent
695 .get(url)
696 .header("Accept", accept)
697 .header("User-Agent", "ci-bill")
698 .header("X-GitHub-Api-Version", "2022-11-28")
699 .header("Connection", "close");
700 if send_auth {
701 call = call.header("Authorization", &auth);
702 }
703 let mut response = call.call().map_err(|err| self.network(&err))?;
704 let status = response.status().as_u16();
705 let remaining = header_value(response.headers(), "x-ratelimit-remaining");
706 let reset = header_value(response.headers(), "x-ratelimit-reset");
707 let retry_after = header_value(response.headers(), "retry-after");
708 let location =
709 header_value(response.headers(), "location").map(|value| self.redact_text(&value));
710 let content_type = header_value(response.headers(), "content-type").unwrap_or_default();
711 let link = header_values(response.headers(), "link")
712 .into_iter()
713 .map(|value| self.redact_text(&value))
714 .collect();
715 let final_url = response
716 .headers()
717 .get("x-final-url")
718 .and_then(|value| value.to_str().ok())
719 .unwrap_or(url)
720 .to_string();
721 let mut body = read_limited(response.body_mut()).map_err(|err| self.network(&err))?;
722 body = self.redact_owned(&body);
723 Ok(Wire {
724 status,
725 remaining,
726 reset,
727 retry_after,
728 location,
729 content_type,
730 link,
731 body,
732 url: self.redact_text(&final_url),
733 })
734 }
735
736 fn network(&self, err: &impl std::fmt::Display) -> FetchError {
737 FetchError::input(self.redact_text(&format!("cannot reach the GitHub API: {err}")))
738 }
739}
740
741#[derive(Debug)]
742struct Wire {
743 status: u16,
744 remaining: Option<String>,
745 reset: Option<String>,
746 retry_after: Option<String>,
747 location: Option<String>,
748 content_type: String,
749 link: Vec<String>,
750 body: Vec<u8>,
751 url: String,
752}
753
754#[derive(Debug)]
755enum Decision {
756 Proceed,
757 Wait(Duration),
758 Unauthorized,
759 Permission,
760 Stop(u16),
761}
762
763fn retry<F, S, N>(
764 mut call: F,
765 mut sleep: S,
766 mut now: N,
767 needles: &[String],
768) -> Result<Wire, FetchError>
769where
770 F: FnMut() -> Result<Wire, FetchError>,
771 S: FnMut(Duration),
772 N: FnMut() -> SystemTime,
773{
774 for attempt in 1..=ATTEMPTS {
775 let wire = call()?;
776 match decide(&wire, attempt, now()) {
777 Decision::Proceed => return Ok(wire),
778 Decision::Wait(delay) => {
779 if attempt == ATTEMPTS {
780 return Err(rate_limit(needles));
781 }
782 sleep(delay);
783 }
784 Decision::Unauthorized => {
785 return Err(FetchError::input(
786 "unauthorized; GH_TOKEN or GITHUB_TOKEN was rejected",
787 ));
788 }
789 Decision::Permission => {
790 return Err(FetchError::input(
791 "permission denied; the token needs the actions:read scope, or the repo scope",
792 ));
793 }
794 Decision::Stop(code) => {
795 return Err(FetchError::input(format!(
796 "the GitHub API returned HTTP {code}"
797 )));
798 }
799 }
800 }
801 Err(rate_limit(needles))
802}
803
804fn rate_limit(needles: &[String]) -> FetchError {
805 FetchError::input(redact_all_str("rate limit; saved files were kept", needles))
806}
807
808fn decide(wire: &Wire, attempt: u32, now: SystemTime) -> Decision {
812 if wire.status != 403 && wire.status != 429 {
813 if wire.status == 401 {
814 return Decision::Unauthorized;
815 }
816 if (500..600).contains(&wire.status) {
817 return Decision::Stop(wire.status);
818 }
819 return Decision::Proceed;
820 }
821 if let Some(raw) = wire.retry_after.as_deref() {
822 if let Some(delay) = parse_retry_after(raw, now) {
823 return Decision::Wait(delay);
824 }
825 }
826 if wire.remaining.as_deref().is_some_and(remaining_is_zero) {
827 let delay = match wire.reset.as_deref().and_then(parse_u64_header) {
828 Some(reset) => Duration::from_secs(reset.saturating_sub(unix_now(now))),
829 None => secondary_delay(attempt),
830 };
831 return Decision::Wait(delay);
832 }
833 if wire.status == 429 || contains_secondary(&wire.body) {
834 return Decision::Wait(secondary_delay(attempt));
835 }
836 if wire.status == 403 {
837 return Decision::Permission;
838 }
839 Decision::Wait(secondary_delay(attempt))
840}
841
842fn remaining_is_zero(value: &str) -> bool {
843 let text = value.trim();
844 !text.is_empty() && text.bytes().all(|byte| byte == b'0')
845}
846
847fn secondary_delay(attempt: u32) -> Duration {
848 let shift = attempt.saturating_sub(1).min(16);
849 Duration::from_secs(60u64.saturating_mul(1u64 << shift))
850}
851
852fn contains_secondary(body: &[u8]) -> bool {
853 const NEEDLE: &[u8] = b"secondary rate limit";
854 if body.len() < NEEDLE.len() {
855 return false;
856 }
857 body.windows(NEEDLE.len()).any(|window| {
858 window
859 .iter()
860 .zip(NEEDLE)
861 .all(|(byte, needle)| byte.to_ascii_lowercase() == *needle)
862 })
863}
864
865fn parse_retry_after(value: &str, now: SystemTime) -> Option<Duration> {
866 let text = value.trim();
867 if let Ok(seconds) = text.parse::<u64>() {
868 return Some(Duration::from_secs(seconds));
869 }
870 let when = parse_http_date(text)?;
871 Some(Duration::from_secs(when.saturating_sub(unix_now(now))))
872}
873
874fn parse_u64_header(value: &str) -> Option<u64> {
875 value.trim().parse().ok()
876}
877
878fn unix_now(now: SystemTime) -> u64 {
879 now.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()
880}
881
882fn parse_http_date(text: &str) -> Option<u64> {
883 let rest = text
884 .trim()
885 .split_once(',')
886 .map(|(_, rest)| rest.trim())
887 .unwrap_or(text.trim());
888 let mut parts = rest.split_whitespace();
889 let day: u32 = parts.next()?.parse().ok()?;
890 let month = month_index(parts.next()?)?;
891 let year: i32 = parts.next()?.parse().ok()?;
892 let time = parts.next()?;
893 let mut clock = time.split(':');
894 let hour: u32 = clock.next()?.parse().ok()?;
895 let minute: u32 = clock.next()?.parse().ok()?;
896 let second: u32 = clock.next()?.parse().ok()?;
897 if clock.next().is_some() {
898 return None;
899 }
900 let zone = parts.next()?;
901 if !zone.eq_ignore_ascii_case("GMT") && !zone.eq_ignore_ascii_case("UTC") {
902 return None;
903 }
904 if !valid_ymd(year, month, day) || hour > 23 || minute > 59 || second > 59 {
905 return None;
906 }
907 let days = days_from_civil(year, month, day);
908 if days < 0 {
909 return None;
910 }
911 Some(days as u64 * 86400 + u64::from(hour) * 3600 + u64::from(minute) * 60 + u64::from(second))
912}
913
914fn month_index(name: &str) -> Option<u32> {
915 Some(match name {
916 "Jan" => 1,
917 "Feb" => 2,
918 "Mar" => 3,
919 "Apr" => 4,
920 "May" => 5,
921 "Jun" => 6,
922 "Jul" => 7,
923 "Aug" => 8,
924 "Sep" => 9,
925 "Oct" => 10,
926 "Nov" => 11,
927 "Dec" => 12,
928 _ => return None,
929 })
930}
931
932fn header_value(headers: &ureq::http::HeaderMap, name: &str) -> Option<String> {
933 headers
934 .get(name)
935 .and_then(|value| value.to_str().ok())
936 .map(str::to_string)
937}
938
939fn header_values(headers: &ureq::http::HeaderMap, name: &str) -> Vec<String> {
940 headers
941 .get_all(name)
942 .iter()
943 .filter_map(|value| value.to_str().ok().map(str::to_string))
944 .collect()
945}
946
947fn read_limited(body: &mut ureq::Body) -> Result<Vec<u8>, ureq::Error> {
948 let mut reader = body.as_reader().take(MAX_BODY + 1);
949 let mut buf = Vec::new();
950 reader.read_to_end(&mut buf).map_err(ureq::Error::Io)?;
951 if buf.len() as u64 > MAX_BODY {
952 return Err(ureq::Error::BodyExceedsLimit(MAX_BODY));
953 }
954 Ok(buf)
955}
956
957struct RunPage {
958 body: Vec<u8>,
959 runs: Vec<Value>,
960 total: Option<u64>,
961 next: Option<String>,
962}
963
964fn page_too_wide(page: &RunPage) -> bool {
965 page.total.unwrap_or(0) >= 1000 || page.runs.len() >= 1000
966}
967
968fn parse_runs_page(wire: &Wire, request_url: &str) -> Result<RunPage, FetchError> {
969 let value = parse_json_value(&wire.body)?;
970 let obj = value.as_object().ok_or_else(invalid_json)?;
971 let total = obj.get("total_count").and_then(json_u64);
972 let items = obj
973 .get("workflow_runs")
974 .and_then(Value::as_array)
975 .ok_or_else(invalid_json)?;
976 let mut runs = Vec::with_capacity(items.len());
977 for item in items {
978 validate_run(item)?;
979 runs.push(item.clone());
980 }
981 let next = next_url(&wire.link, request_url);
982 Ok(RunPage {
983 body: wire.body.clone(),
984 runs,
985 total,
986 next,
987 })
988}
989
990fn validate_run(value: &Value) -> Result<(), FetchError> {
991 let obj = value.as_object().ok_or_else(invalid_json)?;
992 require_u64(obj, "id")?;
993 require_string(obj, "path")?;
994 require_u64(obj, "run_attempt")?;
995 require_u64(obj, "run_number")?;
996 require_string(obj, "event")?;
997 optional_string(obj, "conclusion")?;
998 optional_string(obj, "run_started_at")?;
999 Ok(())
1000}
1001
1002struct JobsPage {
1003 jobs: usize,
1004 next: Option<String>,
1005}
1006
1007fn parse_jobs_page(wire: &Wire, run_id: u64, request_url: &str) -> Result<JobsPage, FetchError> {
1008 let value = parse_json_value(&wire.body)?;
1009 let obj = value.as_object().ok_or_else(invalid_json)?;
1010 let items = obj
1011 .get("jobs")
1012 .and_then(Value::as_array)
1013 .ok_or_else(invalid_json)?;
1014 for item in items {
1015 validate_job(item, run_id)?;
1016 }
1017 Ok(JobsPage {
1018 jobs: items.len(),
1019 next: next_url(&wire.link, request_url),
1020 })
1021}
1022
1023fn validate_job(value: &Value, expected_run: u64) -> Result<(), FetchError> {
1024 let obj = value.as_object().ok_or_else(invalid_json)?;
1025 require_u64(obj, "id")?;
1026 let run_id = require_u64(obj, "run_id")?;
1027 if run_id != expected_run {
1028 return Err(FetchError::input("run id does not match file name"));
1029 }
1030 require_u64(obj, "run_attempt")?;
1031 require_string(obj, "name")?;
1032 optional_string(obj, "started_at")?;
1033 optional_string(obj, "completed_at")?;
1034 optional_string(obj, "conclusion")?;
1035 optional_string(obj, "runner_name")?;
1036 match obj.get("labels") {
1037 None | Some(Value::Null) => {}
1038 Some(Value::Array(items)) if items.iter().all(|item| item.as_str().is_some()) => {}
1039 Some(_) => return Err(invalid_json()),
1040 }
1041 Ok(())
1042}
1043
1044fn require_u64(obj: &Map<String, Value>, key: &str) -> Result<u64, FetchError> {
1045 obj.get(key).and_then(json_u64).ok_or_else(invalid_json)
1046}
1047
1048fn require_string(obj: &Map<String, Value>, key: &str) -> Result<(), FetchError> {
1049 obj.get(key)
1050 .and_then(Value::as_str)
1051 .ok_or_else(invalid_json)
1052 .map(|_| ())
1053}
1054
1055fn optional_string(obj: &Map<String, Value>, key: &str) -> Result<(), FetchError> {
1056 match obj.get(key) {
1057 None | Some(Value::Null) | Some(Value::String(_)) => Ok(()),
1058 Some(_) => Err(invalid_json()),
1059 }
1060}
1061
1062fn json_u64(value: &Value) -> Option<u64> {
1063 if let Some(number) = value.as_u64() {
1064 return Some(number);
1065 }
1066 let float = value.as_f64()?;
1067 if float.is_finite() && float >= 0.0 && float.fract() == 0.0 && float <= u64::MAX as f64 {
1068 Some(float as u64)
1069 } else {
1070 None
1071 }
1072}
1073
1074fn parse_json_value(body: &[u8]) -> Result<Value, FetchError> {
1075 serde_json::from_slice(strip_bom(body)).map_err(|_| invalid_json())
1076}
1077
1078fn invalid_json() -> FetchError {
1079 FetchError::input("invalid JSON from the GitHub API")
1080}
1081
1082fn invalid_contents() -> FetchError {
1083 FetchError::input("invalid contents response")
1084}
1085
1086fn default_branch(body: &[u8]) -> Result<String, FetchError> {
1087 let value = parse_json_value(body)?;
1088 let obj = value.as_object().ok_or_else(invalid_json)?;
1089 let branch = obj
1090 .get("default_branch")
1091 .and_then(Value::as_str)
1092 .map(str::trim)
1093 .filter(|text| !text.is_empty())
1094 .unwrap_or("main");
1095 if !safe_reference(branch) {
1096 return Ok("main".to_string());
1097 }
1098 Ok(branch.to_string())
1099}
1100
1101fn next_url(link_values: &[String], request_url: &str) -> Option<String> {
1102 for value in link_values {
1103 if let Some(url) = next_from_link(value) {
1104 return Some(resolve_url(request_url, &url));
1105 }
1106 }
1107 None
1108}
1109
1110fn next_from_link(value: &str) -> Option<String> {
1111 for part in split_links(value) {
1112 let (url, params) = parse_link_part(part)?;
1113 let is_next = params
1114 .iter()
1115 .any(|(key, rel)| key.eq_ignore_ascii_case("rel") && rel_is_next(rel));
1116 if is_next {
1117 return Some(url);
1118 }
1119 }
1120 None
1121}
1122
1123fn rel_is_next(value: &str) -> bool {
1124 value
1125 .split_whitespace()
1126 .any(|token| token.eq_ignore_ascii_case("next"))
1127}
1128
1129fn split_links(value: &str) -> Vec<&str> {
1130 let mut parts = Vec::new();
1131 let mut start = 0;
1132 let mut angle = 0i32;
1133 let mut quote = false;
1134 for (index, byte) in value.bytes().enumerate() {
1135 match byte {
1136 b'"' => quote = !quote,
1137 b'<' if !quote => angle += 1,
1138 b'>' if !quote && angle > 0 => angle -= 1,
1139 b',' if !quote && angle == 0 => {
1140 let part = value[start..index].trim();
1141 if !part.is_empty() {
1142 parts.push(part);
1143 }
1144 start = index + 1;
1145 }
1146 _ => {}
1147 }
1148 }
1149 let last = value[start..].trim();
1150 if !last.is_empty() {
1151 parts.push(last);
1152 }
1153 parts
1154}
1155
1156fn parse_link_part(part: &str) -> Option<(String, Vec<(String, String)>)> {
1157 let rest = part.trim().strip_prefix('<')?;
1158 let (url, after) = rest.split_once('>')?;
1159 let mut params = Vec::new();
1160 for piece in after.split(';') {
1161 let piece = piece.trim();
1162 if piece.is_empty() {
1163 continue;
1164 }
1165 let (key, value) = piece.split_once('=')?;
1166 params.push((
1167 key.trim().to_string(),
1168 value.trim().trim_matches('"').to_string(),
1169 ));
1170 }
1171 Some((url.to_string(), params))
1172}
1173
1174fn resolve_url(base: &str, link: &str) -> String {
1175 let link = link.trim();
1176 if link.starts_with("https://") || link.starts_with("http://") {
1177 return link.to_string();
1178 }
1179 if let Some(origin) = origin_of(base) {
1180 if let Some(rest) = link.strip_prefix("//") {
1181 let scheme = if base.starts_with("https://") {
1182 "https"
1183 } else {
1184 "http"
1185 };
1186 return format!("{scheme}://{rest}");
1187 }
1188 if link.starts_with('/') {
1189 return format!("{origin}{link}");
1190 }
1191 }
1192 link.to_string()
1193}
1194
1195fn origin_of(url: &str) -> Option<&str> {
1196 let scheme = url.find("://")?;
1197 let after = scheme + 3;
1198 if after >= url.len() {
1199 return None;
1200 }
1201 let end = url[after..]
1202 .find(['/', '?', '#'])
1203 .map(|index| after + index)
1204 .unwrap_or(url.len());
1205 if end == after {
1206 return None;
1207 }
1208 Some(&url[..end])
1209}
1210
1211fn same_origin(base: &str, url: &str) -> bool {
1212 match (origin_of(base), origin_of(url)) {
1213 (Some(left), Some(right)) => left.eq_ignore_ascii_case(right),
1214 _ => false,
1215 }
1216}
1217
1218enum Contents {
1219 Bytes(Vec<u8>),
1220 Download(String),
1221}
1222
1223fn contents_bytes(content_type: &str, body: &[u8]) -> Result<Contents, FetchError> {
1224 let Some(value) = contents_envelope(content_type, body)? else {
1225 return Ok(Contents::Bytes(body.to_vec()));
1226 };
1227 let obj = value.as_object().ok_or_else(invalid_contents)?;
1228 let encoding = obj
1229 .get("encoding")
1230 .and_then(Value::as_str)
1231 .unwrap_or("base64");
1232 let content = obj.get("content");
1233 let empty = match content {
1234 None | Some(Value::Null) => true,
1235 Some(Value::String(text)) => text.trim().is_empty(),
1236 Some(_) => return Err(invalid_contents()),
1237 };
1238 if empty || encoding.eq_ignore_ascii_case("none") {
1239 if let Some(url) = obj.get("download_url").and_then(Value::as_str) {
1240 if !url.is_empty() {
1241 return Ok(Contents::Download(url.to_string()));
1242 }
1243 }
1244 if empty {
1245 return Ok(Contents::Bytes(Vec::new()));
1246 }
1247 }
1248 if encoding.eq_ignore_ascii_case("base64") {
1249 let text = content.and_then(Value::as_str).unwrap_or("");
1250 let bytes = decode_base64(text).ok_or_else(invalid_contents)?;
1251 return Ok(Contents::Bytes(bytes));
1252 }
1253 if let Some(text) = content.and_then(Value::as_str) {
1254 return Ok(Contents::Bytes(text.as_bytes().to_vec()));
1255 }
1256 Err(invalid_contents())
1257}
1258
1259fn contents_envelope(content_type: &str, body: &[u8]) -> Result<Option<Value>, FetchError> {
1260 let kind = content_type.to_ascii_lowercase();
1261 if kind.contains("github.raw") || kind.contains("text/plain") || kind.contains("yaml") {
1262 return Ok(None);
1263 }
1264 let bytes = strip_bom(body);
1265 let trimmed = trim_ascii_start(bytes);
1266 if !kind.contains("json") && !trimmed.starts_with(b"{") {
1267 return Ok(None);
1268 }
1269 if !trimmed.starts_with(b"{") {
1270 return Err(invalid_contents());
1271 }
1272 let value: Value = serde_json::from_slice(trimmed).map_err(|_| invalid_contents())?;
1273 let Some(obj) = value.as_object() else {
1274 return Err(invalid_contents());
1275 };
1276 let envelope = obj.contains_key("encoding")
1277 || obj.contains_key("download_url")
1278 || obj.get("type").and_then(Value::as_str) == Some("file")
1279 || (obj.contains_key("content") && obj.contains_key("sha"));
1280 if envelope {
1281 Ok(Some(value))
1282 } else if obj.contains_key("message") {
1283 Err(invalid_contents())
1284 } else {
1285 Ok(None)
1286 }
1287}
1288
1289fn decode_base64(text: &str) -> Option<Vec<u8>> {
1290 fn value(byte: u8) -> Option<u8> {
1291 match byte {
1292 b'A'..=b'Z' => Some(byte - b'A'),
1293 b'a'..=b'z' => Some(byte - b'a' + 26),
1294 b'0'..=b'9' => Some(byte - b'0' + 52),
1295 b'+' => Some(62),
1296 b'/' => Some(63),
1297 _ => None,
1298 }
1299 }
1300 let bytes: Vec<u8> = text
1301 .bytes()
1302 .filter(|byte| !byte.is_ascii_whitespace())
1303 .collect();
1304 if bytes.is_empty() {
1305 return Some(Vec::new());
1306 }
1307 if bytes.len() % 4 != 0 {
1308 return None;
1309 }
1310 let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
1311 for (index, chunk) in bytes.chunks(4).enumerate() {
1312 let last = index + 1 == bytes.len() / 4;
1313 if !last && (chunk[2] == b'=' || chunk[3] == b'=') {
1314 return None;
1315 }
1316 if chunk[2] == b'=' && chunk[3] != b'=' {
1317 return None;
1318 }
1319 let a = value(chunk[0])?;
1320 let b = value(chunk[1])?;
1321 let c = if chunk[2] == b'=' {
1322 0
1323 } else {
1324 value(chunk[2])?
1325 };
1326 let d = if chunk[3] == b'=' {
1327 0
1328 } else {
1329 value(chunk[3])?
1330 };
1331 out.push((a << 2) | (b >> 4));
1332 if chunk[2] != b'=' {
1333 out.push((b << 4) | (c >> 2));
1334 }
1335 if chunk[3] != b'=' {
1336 out.push((c << 6) | d);
1337 }
1338 }
1339 Some(out)
1340}
1341
1342#[derive(Clone)]
1343struct StoredRun {
1344 attempt: u64,
1345 value: Value,
1346}
1347
1348#[derive(Default)]
1349struct Cache {
1350 runs: BTreeMap<u64, StoredRun>,
1351 runs_files: Vec<PathBuf>,
1352 has_jobs: BTreeSet<u64>,
1353 job_attempt: BTreeMap<u64, u64>,
1354}
1355
1356fn load_cache(dir: &Path) -> Result<Cache, FetchError> {
1357 let mut cache = Cache::default();
1358 let entries = fs::read_dir(dir).map_err(|_| path_error(dir, "cannot read directory"))?;
1359 let mut paths = Vec::new();
1360 for entry in entries {
1361 let entry = entry.map_err(|_| path_error(dir, "cannot read directory"))?;
1362 paths.push(entry.path());
1363 }
1364 paths.sort();
1365 for path in paths {
1366 let Some(name) = path.file_name().and_then(|item| item.to_str()) else {
1367 continue;
1368 };
1369 if is_runs_file(name) {
1370 if !path.is_file() {
1371 return Err(path_error(&path, "cannot read file"));
1372 }
1373 cache.runs_files.push(path.clone());
1374 let text = read_file_text(&path)?;
1375 let value = parse_json_value(text.as_bytes())?;
1376 let items = value
1377 .get("workflow_runs")
1378 .and_then(Value::as_array)
1379 .ok_or_else(invalid_json)?;
1380 for item in items {
1381 validate_run(item)?;
1382 let id = item.get("id").and_then(json_u64).ok_or_else(invalid_json)?;
1383 let attempt = item
1384 .get("run_attempt")
1385 .and_then(json_u64)
1386 .ok_or_else(invalid_json)?;
1387 if cache
1388 .runs
1389 .insert(
1390 id,
1391 StoredRun {
1392 attempt,
1393 value: item.clone(),
1394 },
1395 )
1396 .is_some()
1397 {
1398 return Err(FetchError::input(format!(
1399 "{}: duplicate id",
1400 path.display()
1401 )));
1402 }
1403 }
1404 } else if let Some(run_id) = job_file_run_id(name) {
1405 if !path.is_file() {
1406 return Err(path_error(&path, "cannot read file"));
1407 }
1408 cache.has_jobs.insert(run_id);
1409 let text = read_file_text(&path)?;
1410 let value = parse_json_value(text.as_bytes())?;
1411 let items = value
1412 .get("jobs")
1413 .and_then(Value::as_array)
1414 .ok_or_else(invalid_json)?;
1415 for item in items {
1416 validate_job(item, run_id)?;
1417 let attempt = item
1418 .get("run_attempt")
1419 .and_then(json_u64)
1420 .ok_or_else(invalid_json)?;
1421 let slot = cache.job_attempt.entry(run_id).or_insert(0);
1422 if attempt > *slot {
1423 *slot = attempt;
1424 }
1425 }
1426 }
1427 }
1428 Ok(cache)
1429}
1430
1431fn read_file_text(path: &Path) -> Result<String, FetchError> {
1432 let bytes = fs::read(path).map_err(|_| path_error(path, "cannot read file"))?;
1433 let bytes = strip_bom(&bytes);
1434 String::from_utf8(bytes.to_vec()).map_err(|_| path_error(path, "invalid JSON"))
1435}
1436
1437struct Incoming {
1438 attempt: u64,
1439 value: Value,
1440}
1441
1442struct Merged {
1443 runs: BTreeMap<u64, StoredRun>,
1444 jobs: Vec<(u64, u64)>,
1445 discovered: Vec<Value>,
1446 run_count: usize,
1447 rewrite_runs: bool,
1448}
1449
1450fn incoming_runs(pages: &[RunPage]) -> Result<Vec<Incoming>, FetchError> {
1451 let mut out = Vec::new();
1452 for page in pages {
1453 for run in &page.runs {
1454 out.push(Incoming {
1455 attempt: run
1456 .get("run_attempt")
1457 .and_then(json_u64)
1458 .ok_or_else(invalid_json)?,
1459 value: run.clone(),
1460 });
1461 }
1462 }
1463 Ok(out)
1464}
1465
1466fn merge_runs(cache: &Cache, incoming: &[Incoming]) -> Merged {
1467 let mut best: BTreeMap<u64, StoredRun> = BTreeMap::new();
1468 let mut discovered = Vec::new();
1469 for run in incoming {
1470 let Some(id) = run.value.get("id").and_then(json_u64) else {
1471 continue;
1472 };
1473 discovered.push(run.value.clone());
1474 match best.get(&id) {
1475 Some(old) if run.attempt <= old.attempt => {}
1476 _ => {
1477 best.insert(
1478 id,
1479 StoredRun {
1480 attempt: run.attempt,
1481 value: run.value.clone(),
1482 },
1483 );
1484 }
1485 }
1486 }
1487 let mut runs = cache.runs.clone();
1488 let mut jobs = Vec::new();
1489 for (id, run) in &best {
1490 let stored = cache
1491 .runs
1492 .get(id)
1493 .map(|item| item.attempt)
1494 .or_else(|| cache.job_attempt.get(id).copied());
1495 if should_fetch_jobs(stored, cache.has_jobs.contains(id), run.attempt) {
1496 jobs.push((*id, run.attempt));
1497 }
1498 match runs.get(id) {
1499 Some(old) if run.attempt <= old.attempt => {}
1500 _ => {
1501 runs.insert(*id, run.clone());
1502 }
1503 }
1504 }
1505 let run_count = best.len();
1506 let rewrite_runs = !cache.runs_files.is_empty() || !pages_unique_ids(incoming);
1507 Merged {
1508 runs,
1509 jobs,
1510 discovered,
1511 run_count,
1512 rewrite_runs,
1513 }
1514}
1515
1516fn pages_unique_ids(incoming: &[Incoming]) -> bool {
1517 let mut seen = HashSet::new();
1518 incoming.iter().all(|run| {
1519 run.value
1520 .get("id")
1521 .and_then(json_u64)
1522 .is_some_and(|id| seen.insert(id))
1523 })
1524}
1525
1526fn should_fetch_jobs(stored_attempt: Option<u64>, jobs_present: bool, new_attempt: u64) -> bool {
1527 !matches!(stored_attempt, Some(old) if jobs_present && new_attempt <= old)
1528}
1529
1530struct JobBatch {
1531 id: u64,
1532 bodies: Vec<Vec<u8>>,
1533 jobs: usize,
1534}
1535
1536struct RunWrite<'a> {
1537 cache: &'a Cache,
1538 pages: &'a [RunPage],
1539 merged: &'a BTreeMap<u64, StoredRun>,
1540 rewrite_runs: bool,
1541}
1542
1543fn commit(
1544 out: &Path,
1545 repo_body: &[u8],
1546 runs: &RunWrite<'_>,
1547 jobs: &[JobBatch],
1548 files: &[(PathBuf, Vec<u8>)],
1549) -> Result<(), FetchError> {
1550 fs::create_dir_all(out).map_err(|_| path_error(out, "cannot create directory"))?;
1551 write_bytes(&out.join("repo.json"), repo_body)?;
1552 if !runs.pages.is_empty() {
1553 if runs.rewrite_runs {
1554 write_merged_runs(out, runs.cache, runs.merged)?;
1555 } else {
1556 for (index, page) in runs.pages.iter().enumerate() {
1557 write_bytes(&out.join(format!("runs-{}.json", index + 1)), &page.body)?;
1558 }
1559 }
1560 }
1561 for batch in jobs {
1562 write_job_batch(out, batch)?;
1563 }
1564 for (path, bytes) in files {
1565 if let Some(parent) = path.parent() {
1566 fs::create_dir_all(parent)
1567 .map_err(|_| path_error(parent, "cannot create directory"))?;
1568 }
1569 write_bytes(path, bytes)?;
1570 }
1571 Ok(())
1572}
1573
1574fn write_merged_runs(
1581 out: &Path,
1582 cache: &Cache,
1583 merged: &BTreeMap<u64, StoredRun>,
1584) -> Result<(), FetchError> {
1585 let values: Vec<&Value> = merged.values().map(|run| &run.value).collect();
1586 let mut temps = Vec::new();
1587 for (index, chunk) in values.chunks(100).enumerate() {
1588 let mut body = Map::new();
1589 body.insert("total_count".to_string(), Value::from(chunk.len()));
1590 body.insert(
1591 "workflow_runs".to_string(),
1592 Value::Array(chunk.iter().map(|value| (*value).clone()).collect()),
1593 );
1594 let bytes = serde_json::to_vec(&Value::Object(body)).map_err(|_| invalid_json())?;
1595 let temp = out.join(format!(".part-runs-{}.tmp", index + 1));
1596 if let Err(error) = write_temp(&temp, &bytes) {
1597 remove_temps(&temps);
1598 return Err(error);
1599 }
1600 temps.push(temp);
1601 }
1602 let mut new_paths = Vec::with_capacity(temps.len());
1603 for (index, temp) in temps.iter().enumerate() {
1604 let dest = out.join(format!("runs-{}.json", index + 1));
1605 if let Err(_error) = fs::rename(temp, &dest) {
1606 remove_temps(&temps[index..]);
1607 return Err(path_error(&dest, "cannot write file"));
1608 }
1609 new_paths.push(dest);
1610 }
1611 for path in &cache.runs_files {
1612 if !new_paths.contains(path) {
1613 let _ = fs::remove_file(path);
1614 }
1615 }
1616 Ok(())
1617}
1618
1619fn write_job_batch(out: &Path, batch: &JobBatch) -> Result<(), FetchError> {
1622 let mut temps = Vec::new();
1623 for (index, body) in batch.bodies.iter().enumerate() {
1624 let temp = out.join(format!(".part-jobs-{}-{}.tmp", batch.id, index + 1));
1625 if let Err(error) = write_temp(&temp, body) {
1626 remove_temps(&temps);
1627 return Err(error);
1628 }
1629 temps.push(temp);
1630 }
1631 let old = match job_paths(out, batch.id) {
1632 Ok(paths) => paths,
1633 Err(error) => {
1634 remove_temps(&temps);
1635 return Err(error);
1636 }
1637 };
1638 let mut new_paths = Vec::with_capacity(temps.len());
1639 for (index, temp) in temps.iter().enumerate() {
1640 let name = if index == 0 {
1641 format!("jobs-{}.json", batch.id)
1642 } else {
1643 format!("jobs-{}-{}.json", batch.id, index + 1)
1644 };
1645 let dest = out.join(name);
1646 if let Err(_error) = fs::rename(temp, &dest) {
1647 remove_temps(&temps[index..]);
1648 return Err(path_error(&dest, "cannot write file"));
1649 }
1650 new_paths.push(dest);
1651 }
1652 for path in &old {
1653 if !new_paths.contains(path) {
1654 let _ = fs::remove_file(path);
1655 }
1656 }
1657 Ok(())
1658}
1659
1660fn job_paths(dir: &Path, run_id: u64) -> Result<Vec<PathBuf>, FetchError> {
1661 let entries = fs::read_dir(dir).map_err(|_| path_error(dir, "cannot read directory"))?;
1662 let mut paths = Vec::new();
1663 for entry in entries {
1664 let entry = entry.map_err(|_| path_error(dir, "cannot read directory"))?;
1665 let path = entry.path();
1666 let Some(name) = path.file_name().and_then(|item| item.to_str()) else {
1667 continue;
1668 };
1669 if job_file_run_id(name) == Some(run_id) {
1670 paths.push(path);
1671 }
1672 }
1673 Ok(paths)
1674}
1675
1676fn remove_temps(paths: &[PathBuf]) {
1677 for path in paths {
1678 let _ = fs::remove_file(path);
1679 }
1680}
1681
1682fn write_bytes(path: &Path, bytes: &[u8]) -> Result<(), FetchError> {
1683 fs::write(path, bytes).map_err(|_| path_error(path, "cannot write file"))
1684}
1685
1686fn write_temp(path: &Path, bytes: &[u8]) -> Result<(), FetchError> {
1689 let mut file = fs::File::create(path).map_err(|_| path_error(path, "cannot write file"))?;
1690 file.write_all(bytes)
1691 .map_err(|_| path_error(path, "cannot write file"))?;
1692 file.sync_all()
1693 .map_err(|_| path_error(path, "cannot write file"))?;
1694 Ok(())
1695}
1696
1697fn path_error(path: &Path, message: &str) -> FetchError {
1698 FetchError::input(format!("{}: {message}", path.display()))
1699}
1700
1701fn remote_from_run(
1702 run: &Value,
1703 owner: &str,
1704 repo: &str,
1705 default_branch: &str,
1706) -> Option<RemoteWorkflow> {
1707 let raw = run.get("path")?.as_str()?.trim().trim_matches('\u{feff}');
1708 let (path, path_ref) = match raw.split_once('@') {
1709 Some((path, reference)) if !reference.trim().is_empty() => {
1710 (path.trim(), Some(reference.trim().to_string()))
1711 }
1712 _ => (raw, None),
1713 };
1714 let linked = api::linked_workflow_path(path);
1715 if !is_actions_workflow(&linked) {
1716 return None;
1717 }
1718 let reference = string_field(run, "head_sha")
1719 .or(path_ref)
1720 .unwrap_or_else(|| default_branch.to_string());
1721 if !safe_reference(&reference) {
1722 return None;
1723 }
1724 Some(RemoteWorkflow {
1725 owner: owner.to_string(),
1726 repo: repo.to_string(),
1727 workflow_path: linked.trim_start_matches('/').to_string(),
1728 reference,
1729 })
1730}
1731
1732fn string_field(value: &Value, key: &str) -> Option<String> {
1733 value
1734 .get(key)
1735 .and_then(Value::as_str)
1736 .map(str::trim)
1737 .filter(|text| !text.is_empty())
1738 .map(str::to_string)
1739}
1740
1741fn is_actions_workflow(path: &str) -> bool {
1742 const PREFIX: &str = ".github/workflows/";
1743 if path.len() < PREFIX.len() || !path[..PREFIX.len()].eq_ignore_ascii_case(PREFIX) {
1744 return false;
1745 }
1746 let file = &path[PREFIX.len()..];
1747 if file.is_empty() || file.contains('/') || file.contains('\\') || file.contains('\0') {
1748 return false;
1749 }
1750 match file.rsplit_once('.') {
1751 Some((stem, ext)) if !stem.is_empty() => {
1752 ext.eq_ignore_ascii_case("yml") || ext.eq_ignore_ascii_case("yaml")
1753 }
1754 _ => false,
1755 }
1756}
1757
1758fn safe_reference(reference: &str) -> bool {
1759 if reference.is_empty()
1760 || reference.contains('\\')
1761 || reference.contains('\0')
1762 || reference.starts_with('/')
1763 || reference.ends_with('/')
1764 {
1765 return false;
1766 }
1767 reference
1768 .split('/')
1769 .all(|part| !part.is_empty() && part != "." && part != "..")
1770}
1771
1772fn enqueue(
1773 queue: &mut VecDeque<RemoteWorkflow>,
1774 seen: &mut HashSet<String>,
1775 remote: RemoteWorkflow,
1776) {
1777 if !valid_name(&remote.owner) || !valid_name(&remote.repo) {
1778 return;
1779 }
1780 if !is_actions_workflow(&remote.workflow_path) || !safe_reference(&remote.reference) {
1781 return;
1782 }
1783 let key = format!(
1784 "{}\n{}\n{}\n{}",
1785 remote.owner, remote.repo, remote.workflow_path, remote.reference
1786 );
1787 if seen.insert(key) {
1788 queue.push_back(remote);
1789 }
1790}
1791
1792fn safe_cache_path(out: &Path, remote: &RemoteWorkflow) -> Option<PathBuf> {
1793 let relative = remote.cache_relative();
1794 if relative.as_os_str().is_empty() {
1795 return None;
1796 }
1797 if relative
1798 .components()
1799 .any(|component| !matches!(component, Component::Normal(_)))
1800 {
1801 return None;
1802 }
1803 Some(out.join(relative))
1804}
1805
1806fn strip_bom(bytes: &[u8]) -> &[u8] {
1807 bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes)
1808}
1809
1810fn trim_ascii_start(bytes: &[u8]) -> &[u8] {
1811 let start = bytes
1812 .iter()
1813 .position(|byte| !byte.is_ascii_whitespace())
1814 .unwrap_or(bytes.len());
1815 &bytes[start..]
1816}
1817
1818fn encode_path(path: &str) -> String {
1819 path.split('/')
1820 .map(encode_component)
1821 .collect::<Vec<_>>()
1822 .join("/")
1823}
1824
1825fn encode_component(text: &str) -> String {
1826 let mut out = String::new();
1827 for byte in text.bytes() {
1828 if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
1829 out.push(byte as char);
1830 } else {
1831 const HEX: &[u8] = b"0123456789ABCDEF";
1832 out.push('%');
1833 out.push(HEX[(byte >> 4) as usize] as char);
1834 out.push(HEX[(byte & 0x0f) as usize] as char);
1835 }
1836 }
1837 out
1838}
1839
1840fn redact_all_str(text: &str, needles: &[String]) -> String {
1841 let mut current = text.as_bytes().to_vec();
1842 for needle in needles {
1843 current = redact_bytes(¤t, needle);
1844 }
1845 String::from_utf8(current).unwrap_or_else(|_| "invalid response".to_string())
1846}
1847
1848fn redact_bytes(bytes: &[u8], token: &str) -> Vec<u8> {
1849 let needle = token.as_bytes();
1850 if needle.is_empty() || bytes.len() < needle.len() {
1851 return bytes.to_vec();
1852 }
1853 let mut out = Vec::with_capacity(bytes.len());
1854 let mut index = 0;
1855 while index < bytes.len() {
1856 if bytes[index..].starts_with(needle) {
1857 out.extend_from_slice(b"[redacted]");
1858 index += needle.len();
1859 } else {
1860 out.push(bytes[index]);
1861 index += 1;
1862 }
1863 }
1864 out
1865}
1866
1867fn days_from_civil(year: i32, month: u32, day: u32) -> i64 {
1868 let mut year = i64::from(year);
1869 if month <= 2 {
1870 year -= 1;
1871 }
1872 let era = if year >= 0 { year } else { year - 399 } / 400;
1873 let yoe = (year - era * 400) as u64;
1874 let month_adj = if month > 2 { month - 3 } else { month + 9 };
1875 let doy = (153 * month_adj + 2) / 5 + day - 1;
1876 let doe = yoe * 365 + yoe / 4 - yoe / 100 + u64::from(doy);
1877 era * 146097 + doe as i64 - 719468
1878}
1879
1880fn civil_from_days(days: i64) -> (i32, u32, u32) {
1881 let z = days + 719468;
1882 let era = if z >= 0 { z } else { z - 146096 } / 146097;
1883 let doe = (z - era * 146097) as u64;
1884 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
1885 let year = yoe as i64 + era * 400;
1886 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1887 let mp = (5 * doy + 2) / 153;
1888 let day = doy - (153 * mp + 2) / 5 + 1;
1889 let month = if mp < 10 { mp + 3 } else { mp - 9 };
1890 let year = if month <= 2 { year + 1 } else { year };
1891 (year as i32, month as u32, day as u32)
1892}
1893
1894#[cfg(test)]
1895mod tests {
1896 use super::*;
1897
1898 fn wire(status: u16, body: &str) -> Wire {
1899 Wire {
1900 status,
1901 remaining: None,
1902 reset: None,
1903 retry_after: None,
1904 location: None,
1905 content_type: "application/json".to_string(),
1906 link: Vec::new(),
1907 body: body.as_bytes().to_vec(),
1908 url: "http://example.test/".to_string(),
1909 }
1910 }
1911
1912 fn fixed_now() -> SystemTime {
1913 UNIX_EPOCH + Duration::from_secs(1_700_000_000)
1914 }
1915
1916 #[test]
1917 fn dates_cover_leap_years_and_windows() {
1918 assert!(parse_date("2026-02-29").is_none());
1919 assert!(parse_date("2024-02-29").is_some());
1920 assert!(parse_date("2026-1-01").is_none());
1921 assert!(parse_date("01-01-2026").is_none());
1922 assert_eq!(parse_date(" 2026-01-01 ").unwrap().unix(), 1_767_225_600);
1923 let since = parse_date("2026-01-01").unwrap();
1924 let until = parse_date("2026-01-02").unwrap();
1925 let window = Window {
1926 start: since.unix(),
1927 end: until.next_unix(),
1928 };
1929 assert_eq!(window.created_param(), "2026-01-01..2026-01-02");
1930 let (left, right) = window.split().unwrap();
1931 assert_eq!(left.created_param(), "2026-01-01..2026-01-01");
1932 assert_eq!(right.created_param(), "2026-01-02..2026-01-02");
1933 let wide = Window {
1934 start: since.unix(),
1935 end: parse_date("2026-01-03").unwrap().next_unix(),
1936 };
1937 let (left, right) = wide.split().unwrap();
1938 assert_eq!(
1939 left.created_param(),
1940 "2026-01-01T00:00:00Z..2026-01-02T11:59:59Z"
1941 );
1942 assert_eq!(
1943 right.created_param(),
1944 "2026-01-02T12:00:00Z..2026-01-03T23:59:59Z"
1945 );
1946 assert_eq!(
1947 encode_component(&left.created_param()),
1948 "2026-01-01T00%3A00%3A00Z..2026-01-02T11%3A59%3A59Z"
1949 );
1950 assert!(Window { start: 10, end: 11 }.split().is_none());
1951 assert_eq!(UtcDate::from_unix(1_767_225_600).text(), "2026-01-01");
1952 }
1953
1954 #[test]
1955 fn repo_names_reject_extra_slashes_and_dots() {
1956 assert!(parse_repo_arg("acme/demo").is_ok());
1957 assert!(parse_repo_arg(" Acme/Demo ").is_ok());
1958 assert!(parse_repo_arg("acme").is_err());
1959 assert!(parse_repo_arg("acme/demo/app").is_err());
1960 assert!(parse_repo_arg("acme/").is_err());
1961 assert!(parse_repo_arg("/demo").is_err());
1962 assert!(parse_repo_arg("./demo").is_err());
1963 assert!(parse_repo_arg("acme/.").is_err());
1964 }
1965
1966 #[test]
1967 fn link_header_finds_next_in_any_order_and_case() {
1968 let value =
1969 r#"<https://example.test/last>; rel="last", <https://example.test/next>; rel="Next""#;
1970 assert_eq!(
1971 next_from_link(value).as_deref(),
1972 Some("https://example.test/next")
1973 );
1974 assert_eq!(
1975 next_from_link(r#"</repos/o/r?page=2>; rel=next"#).as_deref(),
1976 Some("/repos/o/r?page=2")
1977 );
1978 let resolved = next_url(
1979 &[r#"</repos/o/r/actions/runs?page=2&per_page=100>; rel="next""#.to_string()],
1980 "http://127.0.0.1:9/repos/o/r/actions/runs?page=1",
1981 );
1982 assert_eq!(
1983 resolved.as_deref(),
1984 Some("http://127.0.0.1:9/repos/o/r/actions/runs?page=2&per_page=100")
1985 );
1986 assert!(next_from_link(r#"<https://example.test/only>; rel="last""#).is_none());
1987 }
1988
1989 #[test]
1990 fn base64_ignores_line_breaks_and_case_of_the_caller() {
1991 let text = "bmFtZTogTGludA==\n";
1992 assert_eq!(decode_base64(text).unwrap(), b"name: Lint");
1993 assert_eq!(decode_base64("").unwrap(), b"");
1994 assert!(decode_base64("====").is_none());
1995 assert!(decode_base64("bmFtZQ").is_none());
1996 let body = br#"{"encoding":"BASE64","content":"bmFtZTogTGludA==","type":"file"}"#;
1997 match contents_bytes("application/json", body).unwrap() {
1998 Contents::Bytes(bytes) => assert_eq!(bytes, b"name: Lint"),
1999 Contents::Download(_) => panic!("expected bytes"),
2000 }
2001 let raw = b"name: CI\r\n";
2002 match contents_bytes("application/vnd.github.raw", raw).unwrap() {
2003 Contents::Bytes(bytes) => assert_eq!(bytes, raw),
2004 Contents::Download(_) => panic!("expected raw bytes"),
2005 }
2006 }
2007
2008 #[test]
2009 fn redaction_removes_every_token_copy() {
2010 let token = "ghs_secret_value";
2011 let body = format!(r#"{{"note":"{token}","again":"{token}"}}"#);
2012 let needles = vec![token.to_string()];
2013 let redacted = redact_all_str(&body, &needles);
2014 assert!(!redacted.contains(token));
2015 assert!(redacted.contains("[redacted]"));
2016 assert_eq!(redact_all_str("plain", &needles), "plain");
2017 }
2018
2019 #[test]
2020 fn rate_limit_order_and_backoff() {
2021 let now = fixed_now();
2022 let mut ok = wire(200, "{}");
2023 ok.remaining = Some("0".to_string());
2024 assert!(matches!(decide(&ok, 1, now), Decision::Proceed));
2025
2026 let mut primary = wire(403, r#"{"message":"secondary rate limit"}"#);
2027 primary.remaining = Some(" 0 ".to_string());
2028 primary.reset = Some("1700000005".to_string());
2029 primary.retry_after = Some("30".to_string());
2030 match decide(&primary, 1, now) {
2031 Decision::Wait(delay) => assert_eq!(delay, Duration::from_secs(30)),
2032 other => panic!("retry-after should win over remaining==0/reset: {other:?}"),
2033 }
2034
2035 let mut primary_no_retry = wire(403, r#"{"message":"secondary rate limit"}"#);
2036 primary_no_retry.remaining = Some(" 0 ".to_string());
2037 primary_no_retry.reset = Some("1700000005".to_string());
2038 match decide(&primary_no_retry, 1, now) {
2039 Decision::Wait(delay) => assert_eq!(delay, Duration::from_secs(5)),
2040 other => panic!("remaining==0 without retry-after should wait until reset: {other:?}"),
2041 }
2042
2043 let mut secondary_429_retry_after = wire(429, "secondary rate limit");
2044 secondary_429_retry_after.remaining = Some("0".to_string());
2045 secondary_429_retry_after.reset = Some("1700000999".to_string());
2046 secondary_429_retry_after.retry_after = Some("3".to_string());
2047 match decide(&secondary_429_retry_after, 1, now) {
2048 Decision::Wait(delay) => assert_eq!(delay, Duration::from_secs(3)),
2049 other => panic!("retry-after on 429 with remaining==0 should still win: {other:?}"),
2050 }
2051
2052 let mut secondary = wire(429, "You have exceeded a SECONDARY RATE LIMIT.");
2053 secondary.retry_after = Some("2".to_string());
2054 secondary.remaining = Some("40".to_string());
2055 match decide(&secondary, 1, now) {
2056 Decision::Wait(delay) => assert_eq!(delay, Duration::from_secs(2)),
2057 other => panic!("retry-after should win: {other:?}"),
2058 }
2059
2060 let bare = wire(403, "secondary rate limit");
2061 assert!(matches!(
2062 decide(&bare, 1, now),
2063 Decision::Wait(delay) if delay == Duration::from_secs(60)
2064 ));
2065 assert!(matches!(
2066 decide(&bare, 2, now),
2067 Decision::Wait(delay) if delay == Duration::from_secs(120)
2068 ));
2069 assert!(matches!(
2070 decide(&bare, 3, now),
2071 Decision::Wait(delay) if delay == Duration::from_secs(240)
2072 ));
2073 assert!(matches!(
2074 decide(&bare, 4, now),
2075 Decision::Wait(delay) if delay == Duration::from_secs(480)
2076 ));
2077
2078 let mut http_date = wire(429, "secondary rate limit");
2079 http_date.retry_after = Some("Wed, 14 Nov 2023 22:13:22 GMT".to_string());
2080 match decide(&http_date, 1, now) {
2081 Decision::Wait(delay) => assert_eq!(delay, Duration::from_secs(2)),
2082 other => panic!("http-date retry-after: {other:?}"),
2083 }
2084
2085 assert!(matches!(
2086 decide(&wire(403, "Resource not accessible"), 1, now),
2087 Decision::Permission
2088 ));
2089 assert!(matches!(
2090 decide(&wire(401, "bad"), 1, now),
2091 Decision::Unauthorized
2092 ));
2093 assert!(matches!(
2094 decide(&wire(500, "down"), 1, now),
2095 Decision::Stop(500)
2096 ));
2097 assert!(matches!(
2098 decide(&wire(404, "missing"), 1, now),
2099 Decision::Proceed
2100 ));
2101
2102 let mut sleeps = Vec::new();
2103 let mut calls = 0;
2104 let needles = vec!["token".to_string()];
2105 let err = retry(
2106 || {
2107 calls += 1;
2108 Ok(wire(403, "secondary rate limit"))
2109 },
2110 |delay| sleeps.push(delay),
2111 || now,
2112 &needles,
2113 );
2114 assert!(err.is_err());
2115 assert_eq!(calls, 5);
2116 assert_eq!(
2117 sleeps,
2118 vec![
2119 Duration::from_secs(60),
2120 Duration::from_secs(120),
2121 Duration::from_secs(240),
2122 Duration::from_secs(480),
2123 ]
2124 );
2125 assert!(!err.unwrap_err().message.contains("token"));
2126 }
2127
2128 #[test]
2129 fn jobs_are_fetched_again_only_when_the_attempt_is_higher_or_missing() {
2130 assert!(!should_fetch_jobs(Some(2), true, 2));
2131 assert!(!should_fetch_jobs(Some(3), true, 2));
2132 assert!(should_fetch_jobs(Some(1), true, 2));
2133 assert!(should_fetch_jobs(Some(2), false, 2));
2134 assert!(should_fetch_jobs(None, true, 1));
2135 assert!(should_fetch_jobs(None, false, 1));
2136 }
2137
2138 #[test]
2139 fn a_one_second_window_uses_utc_timestamps() {
2140 let window = Window {
2141 start: 1_767_225_600,
2142 end: 1_767_225_601,
2143 };
2144 assert_eq!(
2145 window.created_param(),
2146 "2026-01-01T00:00:00Z..2026-01-01T00:00:00Z"
2147 );
2148 }
2149
2150 fn cache_scratch(name: &str) -> PathBuf {
2151 let path =
2152 std::env::temp_dir().join(format!("ci-bill-fetch-unit-{name}-{}", std::process::id()));
2153 let _ = fs::remove_dir_all(&path);
2154 fs::create_dir_all(&path).expect("scratch dir");
2155 path
2156 }
2157
2158 fn stored_run(id: u64) -> StoredRun {
2159 StoredRun {
2160 attempt: 1,
2161 value: serde_json::json!({"id": id, "run_attempt": 1}),
2162 }
2163 }
2164
2165 #[test]
2166 fn write_merged_runs_leaves_old_cache_intact_if_a_temp_write_fails() {
2167 let out = cache_scratch("runs-temp-failure");
2168 let old_body = br#"{"total_count":1,"workflow_runs":[{"id":999,"run_attempt":1}]}"#;
2169 fs::write(out.join("runs-1.json"), old_body).unwrap();
2170 let cache = Cache {
2171 runs_files: vec![out.join("runs-1.json")],
2172 ..Cache::default()
2173 };
2174 let merged: BTreeMap<u64, StoredRun> = (0..150).map(|id| (id, stored_run(id))).collect();
2177 let blocker = out.join(".part-runs-2.tmp");
2178 fs::create_dir_all(blocker.join("occupied")).unwrap();
2179
2180 let err = write_merged_runs(&out, &cache, &merged).unwrap_err();
2181 assert!(err.message.contains("cannot write file"), "{err:?}");
2182 assert!(!out.join(".part-runs-1.tmp").exists());
2183 assert!(!out.join("runs-2.json").exists());
2184 assert_eq!(fs::read(out.join("runs-1.json")).unwrap(), old_body);
2185 }
2186
2187 #[test]
2188 fn write_job_batch_leaves_old_cache_intact_if_a_temp_write_fails() {
2189 let out = cache_scratch("jobs-temp-failure");
2190 let old_body = br#"{"jobs":["old"]}"#;
2191 fs::write(out.join("jobs-7.json"), old_body).unwrap();
2192 let blocker = out.join(".part-jobs-7-2.tmp");
2193 fs::create_dir_all(blocker.join("occupied")).unwrap();
2194 let batch = JobBatch {
2195 id: 7,
2196 bodies: vec![
2197 b"{\"jobs\":[\"new-1\"]}".to_vec(),
2198 b"{\"jobs\":[]}".to_vec(),
2199 ],
2200 jobs: 1,
2201 };
2202
2203 let err = write_job_batch(&out, &batch).unwrap_err();
2204 assert!(err.message.contains("cannot write file"), "{err:?}");
2205 assert!(!out.join(".part-jobs-7-1.tmp").exists());
2206 assert!(!out.join("jobs-7-2.json").exists());
2207 assert_eq!(fs::read(out.join("jobs-7.json")).unwrap(), old_body);
2208 }
2209
2210 #[test]
2211 fn write_merged_runs_drops_stale_files_only_after_new_ones_land() {
2212 let out = cache_scratch("runs-shrink");
2213 let mut runs_files = Vec::new();
2214 for index in 1..=5 {
2215 let path = out.join(format!("runs-{index}.json"));
2216 fs::write(
2217 &path,
2218 format!(
2219 r#"{{"total_count":0,"workflow_runs":[{{"id":{index},"run_attempt":1}}]}}"#
2220 ),
2221 )
2222 .unwrap();
2223 runs_files.push(path);
2224 }
2225 let cache = Cache {
2226 runs_files,
2227 ..Cache::default()
2228 };
2229 let merged: BTreeMap<u64, StoredRun> = [(1, stored_run(1))].into_iter().collect();
2230
2231 write_merged_runs(&out, &cache, &merged).unwrap();
2232
2233 assert!(out.join("runs-1.json").exists());
2234 for index in 2..=5 {
2235 assert!(
2236 !out.join(format!("runs-{index}.json")).exists(),
2237 "runs-{index}.json should have been dropped as stale"
2238 );
2239 }
2240 let saved: Value =
2241 serde_json::from_slice(&fs::read(out.join("runs-1.json")).unwrap()).unwrap();
2242 assert_eq!(saved["workflow_runs"][0]["id"], 1);
2243 }
2244
2245 #[test]
2246 fn write_merged_runs_keeps_every_old_file_if_a_rename_fails() {
2247 let out = cache_scratch("runs-shrink-failed-rename");
2248 let mut runs_files = Vec::new();
2249 for index in 1..=5 {
2250 let path = out.join(format!("runs-{index}.json"));
2251 fs::write(
2252 &path,
2253 format!(
2254 r#"{{"total_count":0,"workflow_runs":[{{"id":{index},"run_attempt":1}}]}}"#
2255 ),
2256 )
2257 .unwrap();
2258 runs_files.push(path);
2259 }
2260 let cache = Cache {
2261 runs_files,
2262 ..Cache::default()
2263 };
2264 let merged: BTreeMap<u64, StoredRun> = [(1, stored_run(1))].into_iter().collect();
2265 fs::remove_file(out.join("runs-1.json")).unwrap();
2268 fs::create_dir_all(out.join("runs-1.json").join("occupied")).unwrap();
2269
2270 let err = write_merged_runs(&out, &cache, &merged).unwrap_err();
2271 assert!(err.message.contains("cannot write file"), "{err:?}");
2272 for index in 2..=5 {
2273 assert!(
2274 out.join(format!("runs-{index}.json")).exists(),
2275 "runs-{index}.json should not be dropped before every rename lands"
2276 );
2277 }
2278 }
2279}