Skip to main content

ci_bill/
fetch.rs

1//! Download Actions runs, jobs, and referenced reusable workflows.
2//!
3//! `fetch` reads `GH_TOKEN` or `GITHUB_TOKEN` and writes the cache that
4//! `--runs` already reads. The token is removed from saved bodies and from
5//! error text.
6
7use 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/// Failure from `fetch`. `code` is the process exit code.
26#[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/// A UTC calendar day.
61#[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    /// Start of the next UTC day.
74    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
88/// `OWNER/REPO` for `--repo`.
89pub fn parse_repo_arg(text: &str) -> Result<String, String> {
90    let (owner, repo) = split_repo(text)?;
91    Ok(format!("{owner}/{repo}"))
92}
93
94/// `YYYY-MM-DD` for `--since` and `--until`.
95pub 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
99/// Fetch one repository into `out`.
100///
101/// The return value is the human-readable summary, including its trailing newline.
102pub 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    /// Value sent in `Authorization`. `GH_TOKEN` wins when both are set.
174    auth: String,
175    /// Every configured token, longest first, removed from saved bytes and errors.
176    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    /// Inclusive unix second.
312    start: u64,
313    /// Exclusive unix second.
314    end: u64,
315}
316
317impl Window {
318    /// Half-open halves, so a run on the boundary is requested once.
319    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(&current, 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, &current);
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(&current, 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
808/// Primary limit (`x-ratelimit-remaining: 0`) waits until `x-ratelimit-reset`.
809/// `retry-after` waits that many seconds. A secondary limit with neither header
810/// waits 60 seconds and then doubles, for at most five attempts.
811fn 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
1574/// Write every new runs file to a temp path first, `rename` each temp over its
1575/// destination (replacing it atomically, on Windows too), and only once every
1576/// rename has landed remove whichever old runs files the new set dropped.
1577/// The old cache is never missing a file: either a temp write fails and
1578/// nothing on disk has changed yet, or every rename succeeds before any old
1579/// file is touched.
1580fn 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
1619/// Same write-temp/rename/clean-up-stale ordering as [`write_merged_runs`],
1620/// for one run's job pages.
1621fn 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
1686/// Write and `fsync` a temp file before it is renamed over a cache file, so
1687/// the rename always has durable content to make live.
1688fn 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(&current, 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        // Force a second chunk, then block its temp write by occupying its path
2175        // with a non-empty directory before any rename has a chance to run.
2176        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        // runs-1.json is a non-empty directory, so the rename that would
2266        // replace it with the new temp file fails.
2267        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}