Skip to main content

rustferry_github/
transport.rs

1//! Bounded GitHub REST transport for remote build orchestration.
2//!
3//! Every request uses an explicit repository endpoint and fixed `gh api`
4//! arguments. The process runner clears ambient environment state, supplies
5//! exactly one configured authentication source, caps captured output, and
6//! never returns command stderr in errors.
7
8use std::{
9    collections::BTreeSet,
10    error::Error,
11    ffi::{OsStr, OsString},
12    fmt, fs,
13    io::{Read, Write},
14    path::{Component, Path, PathBuf},
15    process::{Child, Command, ExitStatus, Stdio},
16    sync::{
17        Arc,
18        mpsc::{self, Receiver, RecvTimeoutError},
19    },
20    thread,
21    time::{Duration, Instant},
22};
23
24#[cfg(not(windows))]
25use std::fs::OpenOptions;
26#[cfg(any(not(windows), feature = "secure-job-log-http"))]
27use std::io;
28#[cfg(unix)]
29use std::os::unix::fs::OpenOptionsExt as _;
30#[cfg(windows)]
31use std::os::windows::io::AsHandle as _;
32
33use serde::{Deserialize, Serialize};
34use sha2::{Digest, Sha256};
35
36#[cfg(windows)]
37use rustferry_core::windows_private_directory::{
38    PrivateDirectoryCleanupStatus, PrivateDirectoryError, PrivateDirectoryErrorKind,
39    create_private_file as create_windows_private_file,
40    remove_private_file_handle as remove_windows_private_file_handle,
41    verify_private_file_handle as verify_windows_private_file_handle,
42};
43use rustferry_remote::SecretBytes;
44
45#[cfg(feature = "secure-job-log-http")]
46use crate::job_logs::{
47    GithubJobLogAccept, GithubJobLogAuthorization, GithubJobLogHttpBody, GithubJobLogHttpClient,
48    GithubJobLogHttpError, GithubJobLogHttpRequest, GithubJobLogHttpResponse,
49};
50use crate::workflow::{ProtectedEnvironment, SecretName, TemporaryBranchNamespace};
51
52const GITHUB_HOST: &str = "github.com";
53const API_ACCEPT: &str = "Accept: application/vnd.github+json";
54const API_VERSION: &str = "X-GitHub-Api-Version: 2022-11-28";
55const WORKFLOW_DISPATCH_API_VERSION: &str = "2026-03-10";
56const WORKFLOW_DISPATCH_API_VERSION_HEADER: &str = "X-GitHub-Api-Version: 2026-03-10";
57const WORKFLOW_DISPATCH_ACCEPT: &str = "application/vnd.github+json";
58const WORKFLOW_DISPATCH_CONTENT_TYPE: &str = "application/json";
59const MAX_WORKFLOW_DISPATCH_BODY_BYTES: usize = 2 * 1024;
60const MAX_WORKFLOW_DISPATCH_RESPONSE_BYTES: usize = 4 * 1024;
61const MAX_STDERR_BYTES: usize = 64 * 1024;
62const MAX_SECRET_SET_OUTPUT_BYTES: usize = 4 * 1024;
63#[cfg(feature = "secure-job-log-http")]
64const MAX_GITHUB_TOKEN_BYTES: usize = 4 * 1024;
65#[cfg(feature = "secure-job-log-http")]
66const GH_AUTH_TOKEN_OUTPUT_BYTES: usize = MAX_GITHUB_TOKEN_BYTES + 2;
67#[cfg(feature = "secure-job-log-http")]
68const GH_AUTH_TOKEN_TIMEOUT: Duration = Duration::from_secs(30);
69#[cfg(feature = "secure-job-log-http")]
70const JOB_LOG_API_ORIGIN: &str = "https://api.github.com";
71#[cfg(feature = "secure-job-log-http")]
72const JOB_LOG_API_ACCEPT: &str = "application/vnd.github+json";
73#[cfg(feature = "secure-job-log-http")]
74const JOB_LOG_PLAINTEXT_ACCEPT: &str = "text/plain";
75#[cfg(feature = "secure-job-log-http")]
76const JOB_LOG_API_VERSION: &str = "2026-03-10";
77#[cfg(feature = "secure-job-log-http")]
78const MAX_JOB_LOG_RESPONSE_HEADER_BYTES: usize = 8 * 1024;
79#[cfg(feature = "secure-job-log-http")]
80const MAX_JOB_LOG_HTTP_CHUNK_BYTES: usize = 64 * 1024 + 1;
81#[cfg(feature = "secure-job-log-http")]
82const MAX_JOB_LOG_HTTP_TIMEOUT: Duration = Duration::from_hours(1);
83#[cfg(feature = "secure-job-log-http")]
84const _: () = assert!(
85    (log::STATIC_MAX_LEVEL as usize) <= (log::LevelFilter::Debug as usize),
86    "secure-job-log-http must compile out URL-bearing ureq TRACE diagnostics"
87);
88const PROCESS_CLEANUP_GRACE: Duration = Duration::from_secs(1);
89const PROCESS_POLL_INTERVAL: Duration = Duration::from_millis(20);
90const MAX_API_RESPONSE_BYTES: usize = 16 * 1024 * 1024;
91const MAX_ARTIFACT_BYTES: u64 = 2 * 1024 * 1024 * 1024;
92const MAX_PAGES: u16 = 100;
93const MAX_POLL_ATTEMPTS: u16 = 2_000;
94const MAX_DEPLOYMENT_POLICY_NAME_BYTES: usize = 255;
95const MAX_WORKFLOW_DISPATCH_RUN_NAME_BYTES: usize = 240;
96const WORKFLOW_DISPATCH_RUN_NAME_PREFIX: &str = "rustferry-v1|";
97const ZIP_END_RECORD_MINIMUM_BYTES: usize = 22;
98const ZIP_END_RECORD_SEARCH_BYTES: usize = 65_557;
99
100/// Maximum value accepted by GitHub Actions for one Environment secret.
101pub const MAX_ENVIRONMENT_SECRET_BYTES: usize = 48 * 1024;
102
103const USER_QUERY: &str = "[.id,(.login | @base64)] | @tsv";
104const INSTALLATION_REPOSITORY_QUERY: &str = ".repositories[] | [.id,(.full_name | @base64)] | @tsv";
105const REPOSITORY_QUERY: &str =
106    "[.id,.full_name,.private,.archived,.disabled,.default_branch] | @tsv";
107const ACTIONS_PERMISSIONS_QUERY: &str = ".enabled";
108const WORKFLOW_QUERY: &str = "[.id,(.path | @base64),.state] | @tsv";
109const RUN_LIST_QUERY: &str = ".workflow_runs[] | [.id,.workflow_id,(.path | @base64),.run_number,.run_attempt,.head_sha,(.head_branch | @base64),.event,.status,(.conclusion // \"\")] | @tsv";
110const RUN_QUERY: &str = "[.id,.workflow_id,(.path | @base64),.run_number,.run_attempt,.head_sha,(.head_branch | @base64),.event,.status,(.conclusion // \"\")] | @tsv";
111const WORKFLOW_DISPATCH_RUN_LIST_QUERY: &str = ".workflow_runs[] | [.id,.workflow_id,(.path | @base64),.run_number,.run_attempt,.head_sha,(.head_branch | @base64),.event,.status,(.conclusion // \"\"),(.display_title | @base64)] | @tsv";
112const WORKFLOW_DISPATCH_RUN_QUERY: &str = "[.id,.workflow_id,(.path | @base64),.run_number,.run_attempt,.head_sha,(.head_branch | @base64),.event,.status,(.conclusion // \"\"),(.display_title | @base64)] | @tsv";
113const ARTIFACT_LIST_QUERY: &str =
114    ".artifacts[] | [.id,(.name | @base64),.size_in_bytes,.expired,(.digest // \"\")] | @tsv";
115const ENVIRONMENT_QUERY: &str = "[(.name | @base64),.deployment_branch_policy.protected_branches,.deployment_branch_policy.custom_branch_policies,([.protection_rules[]? | select(.type == \"required_reviewers\") | .reviewers[]?] | length > 0)] | @tsv";
116const DEPLOYMENT_BRANCH_POLICY_LIST_QUERY: &str =
117    ".branch_policies[] | [.id,(.name | @base64)] | @tsv";
118const ENVIRONMENT_SECRET_LIST_QUERY: &str = ".secrets[] | [(.name | @base64)] | @tsv";
119
120/// Validation failure for a GitHub identifier or local policy value.
121#[derive(Clone, Copy, Debug, Eq, PartialEq)]
122pub enum TransportConfigError {
123    /// A required field was empty.
124    Empty {
125        /// Stable field name; rejected input is intentionally omitted.
126        field: &'static str,
127    },
128    /// A field exceeded its byte limit.
129    TooLong {
130        /// Stable field name.
131        field: &'static str,
132        /// Maximum accepted byte count.
133        maximum: usize,
134    },
135    /// A byte fell outside the field allowlist.
136    InvalidCharacter {
137        /// Stable field name.
138        field: &'static str,
139        /// Byte offset, without the rejected byte.
140        index: usize,
141    },
142    /// A field did not satisfy its structural rules.
143    InvalidFormat {
144        /// Stable field name.
145        field: &'static str,
146    },
147    /// A numeric value was outside its inclusive range.
148    OutOfRange {
149        /// Stable field name.
150        field: &'static str,
151        /// Inclusive minimum.
152        minimum: u64,
153        /// Inclusive maximum.
154        maximum: u64,
155    },
156    /// A local executable or directory was not a usable absolute path.
157    InvalidLocalPath {
158        /// Stable path role; the actual path is intentionally omitted.
159        field: &'static str,
160    },
161    /// A branch was outside the configured temporary-ref namespace.
162    RefOutsideTemporaryNamespace,
163}
164
165impl fmt::Display for TransportConfigError {
166    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
167        match self {
168            Self::Empty { field } => write!(formatter, "{field} is empty"),
169            Self::TooLong { field, maximum } => {
170                write!(formatter, "{field} exceeds {maximum} bytes")
171            }
172            Self::InvalidCharacter { field, index } => {
173                write!(
174                    formatter,
175                    "{field} contains an unsafe byte at offset {index}"
176                )
177            }
178            Self::InvalidFormat { field } => write!(formatter, "{field} has an invalid format"),
179            Self::OutOfRange {
180                field,
181                minimum,
182                maximum,
183            } => write!(
184                formatter,
185                "{field} must be between {minimum} and {maximum} inclusive"
186            ),
187            Self::InvalidLocalPath { field } => {
188                write!(formatter, "{field} is not a usable absolute path")
189            }
190            Self::RefOutsideTemporaryNamespace => {
191                formatter.write_str("branch is outside the temporary job namespace")
192            }
193        }
194    }
195}
196
197impl Error for TransportConfigError {}
198
199/// Explicit GitHub owner and repository name.
200#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
201pub struct Repository {
202    owner: String,
203    name: String,
204}
205
206impl Repository {
207    /// Validate a GitHub.com owner and repository name.
208    ///
209    /// # Errors
210    ///
211    /// Rejects path syntax, control bytes, option-like names, and values above
212    /// GitHub's documented name limits.
213    pub fn new(
214        owner: impl Into<String>,
215        name: impl Into<String>,
216    ) -> Result<Self, TransportConfigError> {
217        let owner = owner.into();
218        let name = name.into();
219        validate_owner(&owner)?;
220        validate_repository_name(&name)?;
221        Ok(Self { owner, name })
222    }
223
224    /// GitHub owner or organization login.
225    pub fn owner(&self) -> &str {
226        &self.owner
227    }
228
229    /// Repository name without its owner.
230    pub fn name(&self) -> &str {
231        &self.name
232    }
233
234    fn endpoint(&self, suffix: &str) -> String {
235        format!("/repos/{}/{}{}", self.owner, self.name, suffix)
236    }
237
238    fn full_name(&self) -> String {
239        format!("{}/{}", self.owner, self.name)
240    }
241}
242
243/// Exact lowercase 40-character Git commit identifier.
244#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
245pub struct CommitSha(String);
246
247impl CommitSha {
248    /// Validate a full lowercase SHA-1 object identifier.
249    ///
250    /// # Errors
251    ///
252    /// Rejects abbreviated, uppercase, or non-hexadecimal values.
253    pub fn new(value: impl Into<String>) -> Result<Self, TransportConfigError> {
254        let value = value.into();
255        if value.len() != 40 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
256            return Err(TransportConfigError::InvalidFormat {
257                field: "commit sha",
258            });
259        }
260        if value.bytes().any(|byte| byte.is_ascii_uppercase()) {
261            return Err(TransportConfigError::InvalidFormat {
262                field: "commit sha",
263            });
264        }
265        Ok(Self(value))
266    }
267
268    /// Full commit identifier.
269    pub fn as_str(&self) -> &str {
270        &self.0
271    }
272}
273
274/// Exact Git branch name without the `refs/heads/` prefix.
275#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
276pub struct BranchName(String);
277
278impl BranchName {
279    /// Validate a conservative Git branch name.
280    ///
281    /// # Errors
282    ///
283    /// Rejects ref traversal, wildcard syntax, reflog syntax, control bytes,
284    /// full refs, and names longer than 244 bytes.
285    #[allow(clippy::case_sensitive_file_extension_comparisons)]
286    pub fn new(value: impl Into<String>) -> Result<Self, TransportConfigError> {
287        let value = value.into();
288        validate_nonempty_length("branch", &value, 244)?;
289        validate_ascii_allowlist("branch", &value, |byte| {
290            byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'.' | b'_' | b'-')
291        })?;
292        if value.starts_with('-')
293            || value.starts_with('/')
294            || value.starts_with("refs/")
295            || value.ends_with('/')
296            || value.ends_with('.')
297            || value.ends_with(".lock")
298            || value.contains("..")
299            || value.contains("//")
300            || value.contains("@{")
301            || value.split('/').any(|part| part.is_empty() || part == ".")
302        {
303            return Err(TransportConfigError::InvalidFormat { field: "branch" });
304        }
305        Ok(Self(value))
306    }
307
308    /// Branch name without a ref prefix.
309    pub fn as_str(&self) -> &str {
310        &self.0
311    }
312}
313
314/// Exact generated artifact name expected from the workflow.
315#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
316pub struct ArtifactName(String);
317
318impl ArtifactName {
319    /// Validate an artifact name safe for exact comparison.
320    ///
321    /// # Errors
322    ///
323    /// Rejects path separators, expression syntax, control bytes, and values
324    /// longer than 128 bytes.
325    pub fn new(value: impl Into<String>) -> Result<Self, TransportConfigError> {
326        let value = value.into();
327        validate_nonempty_length("artifact name", &value, 128)?;
328        validate_ascii_allowlist("artifact name", &value, |byte| {
329            byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')
330        })?;
331        if !value.as_bytes()[0].is_ascii_alphanumeric()
332            || value.ends_with('.')
333            || value.contains("..")
334        {
335            return Err(TransportConfigError::InvalidFormat {
336                field: "artifact name",
337            });
338        }
339        Ok(Self(value))
340    }
341
342    /// Validated artifact name.
343    pub fn as_str(&self) -> &str {
344        &self.0
345    }
346}
347
348macro_rules! numeric_identifier {
349    ($name:ident, $label:literal) => {
350        #[doc = concat!("Non-zero GitHub ", $label, " identifier.")]
351        #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
352        pub struct $name(u64);
353
354        impl $name {
355            #[doc = concat!("Validate a GitHub ", $label, " identifier.")]
356            ///
357            /// # Errors
358            ///
359            /// Rejects zero, which GitHub never assigns as an identifier.
360            pub fn new(value: u64) -> Result<Self, TransportConfigError> {
361                if value == 0 {
362                    return Err(TransportConfigError::OutOfRange {
363                        field: concat!($label, " id"),
364                        minimum: 1,
365                        maximum: u64::MAX,
366                    });
367                }
368                Ok(Self(value))
369            }
370
371            #[doc = concat!("Return the numeric ", $label, " identifier.")]
372            pub const fn get(self) -> u64 {
373                self.0
374            }
375        }
376    };
377}
378
379numeric_identifier!(WorkflowId, "workflow");
380numeric_identifier!(RunId, "run");
381numeric_identifier!(ArtifactId, "artifact");
382
383/// Supported workflow event used for an exact run lookup.
384#[derive(Clone, Copy, Debug, Eq, PartialEq)]
385pub enum RunEvent {
386    /// A push to the temporary job ref.
387    Push,
388    /// An explicitly enabled manual dispatch.
389    WorkflowDispatch,
390}
391
392impl RunEvent {
393    /// GitHub REST event spelling.
394    pub const fn as_str(self) -> &'static str {
395        match self {
396            Self::Push => "push",
397            Self::WorkflowDispatch => "workflow_dispatch",
398        }
399    }
400
401    fn parse(value: &str) -> Option<Self> {
402        match value {
403            "push" => Some(Self::Push),
404            "workflow_dispatch" => Some(Self::WorkflowDispatch),
405            _ => None,
406        }
407    }
408}
409
410/// A branch proven to be below the configured temporary namespace.
411#[derive(Clone, Debug, Eq, PartialEq)]
412pub struct TemporaryGitRef(BranchName);
413
414impl TemporaryGitRef {
415    /// Bind an exact branch to the generated workflow's temporary namespace.
416    ///
417    /// # Errors
418    ///
419    /// Rejects the namespace root itself and any branch outside it.
420    pub fn new(
421        namespace: &TemporaryBranchNamespace,
422        branch: BranchName,
423    ) -> Result<Self, TransportConfigError> {
424        let prefix = format!("{}/", namespace.as_str());
425        let Some(tail) = branch.as_str().strip_prefix(&prefix) else {
426            return Err(TransportConfigError::RefOutsideTemporaryNamespace);
427        };
428        if tail.is_empty() {
429            return Err(TransportConfigError::RefOutsideTemporaryNamespace);
430        }
431        Ok(Self(branch))
432    }
433
434    /// Exact branch name without `refs/heads/`.
435    pub fn branch(&self) -> &BranchName {
436        &self.0
437    }
438}
439
440/// Bounded metadata, pagination, and artifact-download policy.
441#[derive(Clone, Copy, Debug, Eq, PartialEq)]
442pub struct TransportLimits {
443    pages: u16,
444    per_page: u8,
445    api_response_bytes: usize,
446    artifact_bytes: u64,
447    api_timeout: Duration,
448    download_timeout: Duration,
449}
450
451impl TransportLimits {
452    /// Construct transport limits.
453    ///
454    /// # Errors
455    ///
456    /// Rejects zero, GitHub pagination values above 100, response buffers
457    /// above 16 MiB, artifact bounds above 2 GiB, and timeouts above one hour.
458    pub fn new(
459        pages: u16,
460        per_page: u8,
461        api_response_bytes: usize,
462        artifact_bytes: u64,
463        api_timeout: Duration,
464        download_timeout: Duration,
465    ) -> Result<Self, TransportConfigError> {
466        validate_range(
467            "pagination pages",
468            u64::from(pages),
469            1,
470            u64::from(MAX_PAGES),
471        )?;
472        validate_range("pagination page size", u64::from(per_page), 1, 100)?;
473        validate_range(
474            "API response bytes",
475            usize_to_u64(api_response_bytes),
476            1_024,
477            usize_to_u64(MAX_API_RESPONSE_BYTES),
478        )?;
479        validate_range("artifact bytes", artifact_bytes, 1_024, MAX_ARTIFACT_BYTES)?;
480        validate_duration("API timeout", api_timeout, 1, 600)?;
481        validate_duration("download timeout", download_timeout, 1, 3_600)?;
482        Ok(Self {
483            pages,
484            per_page,
485            api_response_bytes,
486            artifact_bytes,
487            api_timeout,
488            download_timeout,
489        })
490    }
491
492    /// Conservative defaults: ten 100-item pages, 2 MiB API responses,
493    /// 512 MiB artifacts, one-minute API calls, and ten-minute downloads.
494    ///
495    /// # Panics
496    ///
497    /// This cannot panic because all constants satisfy the constructor bounds.
498    pub fn secure_defaults() -> Self {
499        Self::new(
500            10,
501            100,
502            2 * 1024 * 1024,
503            512 * 1024 * 1024,
504            Duration::from_mins(1),
505            Duration::from_mins(10),
506        )
507        .expect("constant transport limits are valid")
508    }
509
510    /// Maximum artifact download size.
511    pub const fn artifact_bytes(self) -> u64 {
512        self.artifact_bytes
513    }
514}
515
516/// Bounded polling policy for one known run.
517#[derive(Clone, Copy, Debug, Eq, PartialEq)]
518pub struct PollPolicy {
519    attempts: u16,
520    interval: Duration,
521}
522
523impl PollPolicy {
524    /// Construct a polling policy.
525    ///
526    /// # Errors
527    ///
528    /// Allows 1–2000 attempts and intervals from 250 ms through 60 seconds.
529    pub fn new(attempts: u16, interval: Duration) -> Result<Self, TransportConfigError> {
530        validate_range(
531            "poll attempts",
532            u64::from(attempts),
533            1,
534            u64::from(MAX_POLL_ATTEMPTS),
535        )?;
536        if !(Duration::from_millis(250)..=Duration::from_mins(1)).contains(&interval) {
537            return Err(TransportConfigError::OutOfRange {
538                field: "poll interval milliseconds",
539                minimum: 250,
540                maximum: 60_000,
541            });
542        }
543        Ok(Self { attempts, interval })
544    }
545
546    /// Default five-second polling for at most two hours.
547    ///
548    /// # Panics
549    ///
550    /// This cannot panic because both constants satisfy the constructor bounds.
551    pub fn secure_defaults() -> Self {
552        Self::new(1_440, Duration::from_secs(5)).expect("constant polling policy is valid")
553    }
554}
555
556/// HTTP method used by a planned `gh api` call.
557#[derive(Clone, Copy, Debug, Eq, PartialEq)]
558pub enum ApiMethod {
559    /// Read one REST resource.
560    Get,
561    /// Invoke an idempotence-sensitive REST action such as run cancellation.
562    Post,
563}
564
565impl ApiMethod {
566    fn as_str(self) -> &'static str {
567        match self {
568            Self::Get => "GET",
569            Self::Post => "POST",
570        }
571    }
572}
573
574/// Fully planned, non-secret `gh api` request.
575#[derive(Clone, Debug, Eq, PartialEq)]
576pub struct GhRequest {
577    method: ApiMethod,
578    endpoint: String,
579    fields: Vec<(String, String)>,
580    jq: Option<&'static str>,
581    silent: bool,
582    output_limit: usize,
583    timeout: Duration,
584    api_version: &'static str,
585}
586
587impl GhRequest {
588    /// HTTP method.
589    pub const fn method(&self) -> ApiMethod {
590        self.method
591    }
592
593    /// Explicit GitHub REST endpoint.
594    pub fn endpoint(&self) -> &str {
595        &self.endpoint
596    }
597
598    /// Maximum accepted stdout bytes.
599    pub const fn output_limit(&self) -> usize {
600        self.output_limit
601    }
602
603    /// Per-process deadline.
604    pub const fn timeout(&self) -> Duration {
605        self.timeout
606    }
607
608    /// Exact `X-GitHub-Api-Version` header planned for this request.
609    pub const fn api_version_header(&self) -> &'static str {
610        self.api_version
611    }
612
613    /// Render fixed argv entries for `gh`; no shell parsing is involved.
614    pub fn arguments(&self) -> Vec<OsString> {
615        let mut arguments = vec![
616            OsString::from("api"),
617            OsString::from("--hostname"),
618            OsString::from(GITHUB_HOST),
619            OsString::from("--method"),
620            OsString::from(self.method.as_str()),
621            OsString::from("-H"),
622            OsString::from(API_ACCEPT),
623            OsString::from("-H"),
624            OsString::from(self.api_version),
625            OsString::from(&self.endpoint),
626        ];
627        for (name, value) in &self.fields {
628            arguments.push(OsString::from("-f"));
629            arguments.push(OsString::from(format!("{name}={value}")));
630        }
631        if let Some(query) = self.jq {
632            arguments.push(OsString::from("--jq"));
633            arguments.push(OsString::from(query));
634        }
635        if self.silent {
636            arguments.push(OsString::from("--silent"));
637        }
638        arguments
639    }
640}
641
642/// Exact secret role and protected GitHub Environment selected for one write.
643///
644/// The secret value is deliberately absent. It is supplied separately to the
645/// runner so it cannot enter argv, debug output, or serialized request state.
646#[derive(Clone, Debug, Eq, PartialEq)]
647pub struct EnvironmentSecretWriteRequest {
648    repository: Repository,
649    environment: ProtectedEnvironment,
650    name: SecretName,
651}
652
653impl EnvironmentSecretWriteRequest {
654    /// Bind one validated secret name to an exact repository and Environment.
655    pub const fn new(
656        repository: Repository,
657        environment: ProtectedEnvironment,
658        name: SecretName,
659    ) -> Self {
660        Self {
661            repository,
662            environment,
663            name,
664        }
665    }
666
667    /// Exact target repository.
668    pub const fn repository(&self) -> &Repository {
669        &self.repository
670    }
671
672    /// Exact protected Environment.
673    pub const fn environment(&self) -> &ProtectedEnvironment {
674        &self.environment
675    }
676
677    /// Validated secret role name.
678    pub const fn name(&self) -> &SecretName {
679        &self.name
680    }
681
682    /// Render the fixed `gh secret set` argv. The value is read from stdin.
683    pub fn arguments(&self) -> Vec<OsString> {
684        vec![
685            OsString::from("secret"),
686            OsString::from("set"),
687            OsString::from(self.name.as_str()),
688            OsString::from("--repo"),
689            OsString::from(self.repository.full_name()),
690            OsString::from("--env"),
691            OsString::from(self.environment.as_str()),
692        ]
693    }
694}
695
696/// Secret-free confirmation that `gh` accepted one exact Environment write.
697#[derive(Clone, Debug, Eq, PartialEq)]
698pub struct EnvironmentSecretWriteReceipt {
699    repository: Repository,
700    environment: ProtectedEnvironment,
701    name: SecretName,
702}
703
704impl EnvironmentSecretWriteReceipt {
705    /// Exact target repository.
706    pub const fn repository(&self) -> &Repository {
707        &self.repository
708    }
709
710    /// Exact protected Environment.
711    pub const fn environment(&self) -> &ProtectedEnvironment {
712        &self.environment
713    }
714
715    /// Secret role accepted by `gh`.
716    pub const fn name(&self) -> &SecretName {
717        &self.name
718    }
719}
720
721/// Redacted execution failure from a `gh` runner.
722#[derive(Clone, Copy, Debug, Eq, PartialEq)]
723pub enum GhExecutionError {
724    /// Authentication was unavailable from the exact configured source.
725    AuthenticationUnavailable,
726    /// A secret value exceeded GitHub's fixed Environment-secret limit.
727    SecretTooLarge,
728    /// The child process could not be created.
729    SpawnFailed,
730    /// The child process exceeded its deadline and was terminated.
731    TimedOut,
732    /// Waiting for or reading the child process failed.
733    ProcessIo,
734    /// Stdout or stderr crossed its configured cap.
735    OutputLimitExceeded,
736    /// `gh` returned a non-zero status. Output is intentionally discarded.
737    CommandFailed {
738        /// Exit code when the platform supplied one.
739        exit_code: Option<i32>,
740    },
741}
742
743impl fmt::Display for GhExecutionError {
744    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
745        match self {
746            Self::AuthenticationUnavailable => {
747                formatter.write_str("GitHub authentication is unavailable")
748            }
749            Self::SecretTooLarge => {
750                formatter.write_str("GitHub Environment secret exceeds size limit")
751            }
752            Self::SpawnFailed => formatter.write_str("failed to start GitHub CLI"),
753            Self::TimedOut => formatter.write_str("GitHub API request timed out"),
754            Self::ProcessIo => formatter.write_str("GitHub CLI process I/O failed"),
755            Self::OutputLimitExceeded => {
756                formatter.write_str("GitHub CLI output exceeded its limit")
757            }
758            Self::CommandFailed { exit_code } => match exit_code {
759                Some(code) => write!(formatter, "GitHub API request failed with status {code}"),
760                None => formatter.write_str("GitHub API request failed"),
761            },
762        }
763    }
764}
765
766impl Error for GhExecutionError {}
767
768/// Injectable executor for planned GitHub API requests.
769pub trait GhRunner {
770    /// Execute one request and return bounded stdout bytes.
771    ///
772    /// # Errors
773    ///
774    /// Returns only redacted process failure categories.
775    fn execute(&mut self, request: &GhRequest) -> Result<Vec<u8>, GhExecutionError>;
776
777    /// Clone a lazy, credential-owning factory for one direct workflow dispatch client.
778    ///
779    /// Implementations must not read credentials or access the network until the returned
780    /// factory is consumed. Callers consume it before publishing a temporary ref or committing a
781    /// durable dispatch intent, so deterministic credential failures cannot strand that intent.
782    ///
783    /// # Errors
784    ///
785    /// Returns a redacted authentication or client-construction failure.
786    fn workflow_dispatch_client_factory(
787        &self,
788    ) -> Result<Box<dyn GithubWorkflowDispatchHttpClientFactory>, GhExecutionError> {
789        Err(GhExecutionError::AuthenticationUnavailable)
790    }
791}
792
793/// Injectable executor for one fixed-argv Environment-secret write.
794pub trait GhSecretRunner {
795    /// Send the value only through child-process stdin.
796    ///
797    /// # Errors
798    ///
799    /// Returns only redacted process failure categories. Implementations must
800    /// reject values above [`MAX_ENVIRONMENT_SECRET_BYTES`].
801    fn set_environment_secret(
802        &mut self,
803        request: &EnvironmentSecretWriteRequest,
804        value: &SecretBytes,
805        timeout: Duration,
806    ) -> Result<(), GhExecutionError>;
807}
808
809/// Exact environment variable accepted as a GitHub token source.
810#[derive(Clone, Copy, Debug, Eq, PartialEq)]
811pub enum TokenEnvironmentVariable {
812    /// GitHub CLI's preferred token variable.
813    GhToken,
814    /// GitHub Actions' standard token variable.
815    GithubToken,
816}
817
818impl TokenEnvironmentVariable {
819    fn as_str(self) -> &'static str {
820        match self {
821            Self::GhToken => "GH_TOKEN",
822            Self::GithubToken => "GITHUB_TOKEN",
823        }
824    }
825}
826
827/// Explicit GitHub CLI authentication source.
828#[derive(Clone, Debug, Eq, PartialEq)]
829pub enum GhAuthentication {
830    /// Read exactly one named token variable and use an isolated config dir.
831    EnvironmentToken {
832        /// Allowlisted variable name; never its value.
833        variable: TokenEnvironmentVariable,
834    },
835    /// Use exactly one canonical `gh` configuration directory.
836    ConfigDirectory(PathBuf),
837}
838
839impl GhAuthentication {
840    /// Configure exact environment-token authentication. The token itself is
841    /// not read until request execution.
842    pub const fn environment_token(variable: TokenEnvironmentVariable) -> Self {
843        Self::EnvironmentToken { variable }
844    }
845
846    /// Configure authentication through one exact `gh` config directory.
847    ///
848    /// # Errors
849    ///
850    /// Rejects a missing, relative, non-directory, or symlink-final path.
851    pub fn config_directory(directory: impl AsRef<Path>) -> Result<Self, TransportConfigError> {
852        Ok(Self::ConfigDirectory(canonical_directory(
853            directory.as_ref(),
854            "gh config directory",
855        )?))
856    }
857}
858
859#[derive(Debug)]
860struct GhPrivateState {
861    _lifetime: tempfile::TempDir,
862    root: PathBuf,
863}
864
865/// Real fixed-argv GitHub CLI process runner.
866#[derive(Clone, Debug)]
867pub struct GhProcessRunner {
868    executable: PathBuf,
869    neutral_working_directory: PathBuf,
870    authentication: GhAuthentication,
871    configuration_directory: PathBuf,
872    private_state: Arc<GhPrivateState>,
873    #[cfg(windows)]
874    system_root: PathBuf,
875}
876
877impl GhProcessRunner {
878    /// Validate the executable, neutral working directory, and auth source.
879    ///
880    /// # Errors
881    ///
882    /// Rejects non-absolute, missing, or unsuitable local paths.
883    pub fn new(
884        executable: impl AsRef<Path>,
885        neutral_working_directory: impl AsRef<Path>,
886        authentication: GhAuthentication,
887    ) -> Result<Self, TransportConfigError> {
888        let executable = canonical_file(executable.as_ref(), "gh executable")?;
889        if !executable_basename_matches(&executable, "gh") {
890            return Err(TransportConfigError::InvalidLocalPath {
891                field: "gh executable",
892            });
893        }
894        let neutral_working_directory = canonical_directory(
895            neutral_working_directory.as_ref(),
896            "neutral working directory",
897        )?;
898        let private_state = Arc::new(create_private_gh_state(&neutral_working_directory)?);
899        let configuration_directory = match &authentication {
900            GhAuthentication::EnvironmentToken { .. } => private_state.root.clone(),
901            GhAuthentication::ConfigDirectory(directory) => directory.clone(),
902        };
903        #[cfg(windows)]
904        let system_root = rustferry_core::windows_system_root().map_err(|_| {
905            TransportConfigError::InvalidLocalPath {
906                field: "Windows system root",
907            }
908        })?;
909        Ok(Self {
910            executable,
911            neutral_working_directory,
912            authentication,
913            configuration_directory,
914            private_state,
915            #[cfg(windows)]
916            system_root,
917        })
918    }
919
920    fn command(&self, request: &GhRequest) -> Command {
921        self.command_with_arguments(request.arguments(), Stdio::null())
922    }
923
924    fn environment_secret_command(&self, request: &EnvironmentSecretWriteRequest) -> Command {
925        self.command_with_arguments(request.arguments(), Stdio::piped())
926    }
927
928    fn command_with_arguments(&self, arguments: Vec<OsString>, stdin: Stdio) -> Command {
929        let mut command = Command::new(&self.executable);
930        command
931            .args(arguments)
932            .current_dir(&self.neutral_working_directory)
933            .env_clear()
934            .env("GH_CONFIG_DIR", &self.configuration_directory)
935            .env("GH_PROMPT_DISABLED", "1")
936            .env("GH_NO_UPDATE_NOTIFIER", "1")
937            .env("GH_TELEMETRY", "false")
938            .env("DO_NOT_TRACK", "true")
939            .env("GH_PAGER", "cat")
940            .env("PAGER", "cat")
941            .env("NO_COLOR", "1")
942            .env("LC_ALL", "C")
943            .env("LANG", "C")
944            .env("XDG_STATE_HOME", &self.private_state.root)
945            .env("HOME", &self.private_state.root)
946            .env("USERPROFILE", &self.private_state.root)
947            .env("APPDATA", &self.private_state.root)
948            .env("LOCALAPPDATA", &self.private_state.root);
949        #[cfg(windows)]
950        command.env("SystemRoot", &self.system_root);
951        command
952            .stdin(stdin)
953            .stdout(Stdio::piped())
954            .stderr(Stdio::piped());
955        configure_process_tree(&mut command);
956        command
957    }
958
959    fn apply_authentication(&self, command: &mut Command) -> Result<(), GhExecutionError> {
960        if let GhAuthentication::EnvironmentToken { variable, .. } = &self.authentication {
961            let name = variable.as_str();
962            let value = std::env::var_os(name)
963                .filter(|value| !value.is_empty())
964                .ok_or(GhExecutionError::AuthenticationUnavailable)?;
965            command.env(name, value);
966        }
967        Ok(())
968    }
969
970    #[cfg(feature = "secure-job-log-http")]
971    fn authentication_token_command(&self) -> Command {
972        self.command_with_arguments(
973            vec![
974                OsString::from("auth"),
975                OsString::from("token"),
976                OsString::from("--hostname"),
977                OsString::from(GITHUB_HOST),
978            ],
979            Stdio::null(),
980        )
981    }
982
983    /// Acquire the configured credential and construct the bounded direct
984    /// GitHub HTTPS client used by API dispatch and attempt-scoped job logs.
985    ///
986    /// Environment authentication reads only its selected allowlisted variable.
987    /// Configuration-directory authentication runs exactly
988    /// `gh auth token --hostname github.com` in the isolated process context.
989    ///
990    /// # Errors
991    ///
992    /// Returns a redacted authentication or bounded process failure. Token
993    /// contents and command output are never included.
994    #[cfg(feature = "secure-job-log-http")]
995    pub fn github_api_http_client(&self) -> Result<UreqGithubApiHttpClient, GhExecutionError> {
996        let token = match &self.authentication {
997            GhAuthentication::EnvironmentToken { variable } => {
998                let value = std::env::var(variable.as_str())
999                    .map_err(|_| GhExecutionError::AuthenticationUnavailable)?;
1000                validate_github_token(value.into_bytes(), false)?
1001            }
1002            GhAuthentication::ConfigDirectory(_) => {
1003                let output = run_process(
1004                    self.authentication_token_command(),
1005                    GH_AUTH_TOKEN_TIMEOUT,
1006                    GH_AUTH_TOKEN_OUTPUT_BYTES,
1007                )?;
1008                validate_github_token(output, true)?
1009            }
1010        };
1011        UreqGithubJobLogHttpClient::new(token)
1012    }
1013
1014    /// Backward-compatible job-log-specific name for the same fixed-origin client.
1015    ///
1016    /// # Errors
1017    ///
1018    /// Returns a redacted authentication or bounded process failure.
1019    #[cfg(feature = "secure-job-log-http")]
1020    pub fn job_log_http_client(&self) -> Result<UreqGithubJobLogHttpClient, GhExecutionError> {
1021        self.github_api_http_client()
1022    }
1023}
1024
1025#[cfg(feature = "secure-job-log-http")]
1026fn validate_github_token(
1027    mut bytes: Vec<u8>,
1028    strip_process_newline: bool,
1029) -> Result<SecretBytes, GhExecutionError> {
1030    if strip_process_newline && bytes.last() == Some(&b'\n') {
1031        bytes.pop();
1032        if bytes.last() == Some(&b'\r') {
1033            bytes.pop();
1034        }
1035    }
1036    if bytes.is_empty()
1037        || bytes.len() > MAX_GITHUB_TOKEN_BYTES
1038        || !bytes.iter().all(|byte| (b'!'..=b'~').contains(byte))
1039    {
1040        let oversized = bytes.len() > MAX_GITHUB_TOKEN_BYTES;
1041        bytes.fill(0);
1042        return Err(if oversized {
1043            GhExecutionError::OutputLimitExceeded
1044        } else {
1045            GhExecutionError::AuthenticationUnavailable
1046        });
1047    }
1048    Ok(SecretBytes::new(bytes))
1049}
1050
1051impl GhRunner for GhProcessRunner {
1052    fn execute(&mut self, request: &GhRequest) -> Result<Vec<u8>, GhExecutionError> {
1053        let mut command = self.command(request);
1054        self.apply_authentication(&mut command)?;
1055        run_process(command, request.timeout, request.output_limit)
1056    }
1057
1058    fn workflow_dispatch_client_factory(
1059        &self,
1060    ) -> Result<Box<dyn GithubWorkflowDispatchHttpClientFactory>, GhExecutionError> {
1061        #[cfg(feature = "secure-job-log-http")]
1062        {
1063            Ok(Box::new(self.clone()))
1064        }
1065        #[cfg(not(feature = "secure-job-log-http"))]
1066        {
1067            Err(GhExecutionError::AuthenticationUnavailable)
1068        }
1069    }
1070}
1071
1072impl GhSecretRunner for GhProcessRunner {
1073    fn set_environment_secret(
1074        &mut self,
1075        request: &EnvironmentSecretWriteRequest,
1076        value: &SecretBytes,
1077        timeout: Duration,
1078    ) -> Result<(), GhExecutionError> {
1079        if value.len() > MAX_ENVIRONMENT_SECRET_BYTES {
1080            return Err(GhExecutionError::SecretTooLarge);
1081        }
1082        let mut command = self.environment_secret_command(request);
1083        self.apply_authentication(&mut command)?;
1084        run_process_with_secret_input(command, timeout, MAX_SECRET_SET_OUTPUT_BYTES, value)
1085    }
1086}
1087
1088/// Production no-auto-redirect HTTPS adapter for attempt-scoped GitHub job logs.
1089///
1090/// The adapter owns the authorization value in zeroing [`SecretBytes`]. It
1091/// ignores ambient proxy variables, permits HTTPS only, returns redirects to
1092/// the caller without following them, and attaches authorization only to the
1093/// fixed GitHub API origin.
1094///
1095/// Enabling `secure-job-log-http` deliberately enables
1096/// `log/max_level_debug`. Cargo feature unification therefore compiles out
1097/// TRACE process-wide for any final binary that opts into this adapter. This
1098/// prevents ureq TRACE diagnostics from exposing signed redirect targets.
1099#[cfg(feature = "secure-job-log-http")]
1100pub struct UreqGithubJobLogHttpClient {
1101    agent: ureq::Agent,
1102    authorization: SecretBytes,
1103}
1104
1105/// Direct fixed-origin GitHub API client; retained alias for additive dispatch use.
1106#[cfg(feature = "secure-job-log-http")]
1107pub type UreqGithubApiHttpClient = UreqGithubJobLogHttpClient;
1108
1109#[cfg(feature = "secure-job-log-http")]
1110impl fmt::Debug for UreqGithubJobLogHttpClient {
1111    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1112        formatter
1113            .debug_struct("UreqGithubJobLogHttpClient")
1114            .field("https_only", &self.agent.config().https_only())
1115            .field("proxy", &"disabled")
1116            .field("redirects", &"disabled")
1117            .field("authorization", &"<redacted>")
1118            .finish()
1119    }
1120}
1121
1122#[cfg(feature = "secure-job-log-http")]
1123impl UreqGithubJobLogHttpClient {
1124    /// Construct an adapter from one owned raw GitHub token.
1125    ///
1126    /// # Errors
1127    ///
1128    /// Rejects empty, oversized, whitespace-containing, or non-ASCII tokens
1129    /// with a redacted authentication failure.
1130    #[allow(
1131        clippy::needless_pass_by_value,
1132        reason = "the adapter must take ownership and zero the supplied credential"
1133    )]
1134    pub fn new(token: SecretBytes) -> Result<Self, GhExecutionError> {
1135        validate_github_token_bytes(token.expose_secret_bytes())?;
1136        let mut authorization = Vec::with_capacity("Bearer ".len() + token.len());
1137        authorization.extend_from_slice(b"Bearer ");
1138        authorization.extend_from_slice(token.expose_secret_bytes());
1139        Ok(Self {
1140            agent: build_job_log_agent(),
1141            authorization: SecretBytes::new(authorization),
1142        })
1143    }
1144
1145    fn prepare_get(
1146        &self,
1147        target: &str,
1148        api_path: bool,
1149        authorization: GithubJobLogAuthorization,
1150        accept: GithubJobLogAccept,
1151        timeout: Duration,
1152    ) -> Result<ureq::RequestBuilder<ureq::typestate::WithoutBody>, GithubJobLogHttpError> {
1153        validate_job_log_request_mode(api_path, authorization, accept, timeout)?;
1154        let target = resolve_job_log_target(target, api_path)?;
1155        let mut builder = self.agent.get(target).header(
1156            "Accept",
1157            match accept {
1158                GithubJobLogAccept::Json => JOB_LOG_API_ACCEPT,
1159                GithubJobLogAccept::PlainText => JOB_LOG_PLAINTEXT_ACCEPT,
1160            },
1161        );
1162        if authorization == GithubJobLogAuthorization::GithubApi {
1163            let mut value =
1164                ureq::http::HeaderValue::from_bytes(self.authorization.expose_secret_bytes())
1165                    .map_err(|_| GithubJobLogHttpError::Protocol)?;
1166            value.set_sensitive(true);
1167            builder = builder
1168                .header("Authorization", value)
1169                .header("X-GitHub-Api-Version", JOB_LOG_API_VERSION);
1170        }
1171        Ok(builder
1172            .config()
1173            .timeout_global(Some(timeout))
1174            .timeout_resolve(Some(timeout))
1175            .timeout_connect(Some(timeout))
1176            .timeout_send_request(Some(timeout))
1177            .timeout_recv_response(Some(timeout))
1178            .timeout_recv_body(Some(timeout))
1179            .build())
1180    }
1181
1182    fn prepare_workflow_dispatch_post(
1183        &self,
1184        request: &GithubWorkflowDispatchHttpRequest,
1185    ) -> Result<ureq::RequestBuilder<ureq::typestate::WithBody>, GithubWorkflowDispatchHttpError>
1186    {
1187        let target = resolve_workflow_dispatch_target(request.endpoint())?;
1188        let mut authorization =
1189            ureq::http::HeaderValue::from_bytes(self.authorization.expose_secret_bytes())
1190                .map_err(|_| GithubWorkflowDispatchHttpError::Protocol)?;
1191        authorization.set_sensitive(true);
1192        Ok(self
1193            .agent
1194            .post(target)
1195            .header("Accept", request.accept())
1196            .header("Content-Type", request.content_type())
1197            .header("X-GitHub-Api-Version", request.api_version())
1198            .header("Authorization", authorization)
1199            .config()
1200            .timeout_global(Some(request.timeout()))
1201            .timeout_resolve(Some(request.timeout()))
1202            .timeout_connect(Some(request.timeout()))
1203            .timeout_send_request(Some(request.timeout()))
1204            .timeout_recv_response(Some(request.timeout()))
1205            .timeout_recv_body(Some(request.timeout()))
1206            .build())
1207    }
1208}
1209
1210#[cfg(feature = "secure-job-log-http")]
1211fn validate_github_token_bytes(bytes: &[u8]) -> Result<(), GhExecutionError> {
1212    if bytes.is_empty()
1213        || bytes.len() > MAX_GITHUB_TOKEN_BYTES
1214        || !bytes.iter().all(|byte| (b'!'..=b'~').contains(byte))
1215    {
1216        return Err(if bytes.len() > MAX_GITHUB_TOKEN_BYTES {
1217            GhExecutionError::OutputLimitExceeded
1218        } else {
1219            GhExecutionError::AuthenticationUnavailable
1220        });
1221    }
1222    Ok(())
1223}
1224
1225#[cfg(feature = "secure-job-log-http")]
1226fn build_job_log_agent() -> ureq::Agent {
1227    let tls = ureq::tls::TlsConfig::builder()
1228        .provider(ureq::tls::TlsProvider::Rustls)
1229        .build();
1230    let config = ureq::Agent::config_builder()
1231        .http_status_as_error(false)
1232        .https_only(true)
1233        .tls_config(tls)
1234        .proxy(None)
1235        .max_redirects(0)
1236        .max_redirects_will_error(false)
1237        .redirect_auth_headers(ureq::config::RedirectAuthHeaders::Never)
1238        .save_redirect_history(false)
1239        .user_agent(concat!("rustferry/", env!("CARGO_PKG_VERSION")))
1240        .accept("")
1241        .accept_encoding("")
1242        .timeout_global(Some(MAX_JOB_LOG_HTTP_TIMEOUT))
1243        .timeout_resolve(Some(MAX_JOB_LOG_HTTP_TIMEOUT))
1244        .timeout_connect(Some(MAX_JOB_LOG_HTTP_TIMEOUT))
1245        .timeout_send_request(Some(MAX_JOB_LOG_HTTP_TIMEOUT))
1246        .timeout_recv_response(Some(MAX_JOB_LOG_HTTP_TIMEOUT))
1247        .timeout_recv_body(Some(MAX_JOB_LOG_HTTP_TIMEOUT))
1248        .max_response_header_size(MAX_JOB_LOG_RESPONSE_HEADER_BYTES)
1249        .build();
1250    config.into()
1251}
1252
1253#[cfg(feature = "secure-job-log-http")]
1254fn validate_job_log_request_mode(
1255    api_path: bool,
1256    authorization: GithubJobLogAuthorization,
1257    accept: GithubJobLogAccept,
1258    timeout: Duration,
1259) -> Result<(), GithubJobLogHttpError> {
1260    if timeout.is_zero()
1261        || timeout > MAX_JOB_LOG_HTTP_TIMEOUT
1262        || !matches!(
1263            (api_path, authorization, accept),
1264            (
1265                true,
1266                GithubJobLogAuthorization::GithubApi,
1267                GithubJobLogAccept::Json
1268            ) | (
1269                false,
1270                GithubJobLogAuthorization::Omit,
1271                GithubJobLogAccept::PlainText
1272            )
1273        )
1274    {
1275        return Err(GithubJobLogHttpError::Protocol);
1276    }
1277    Ok(())
1278}
1279
1280#[cfg(feature = "secure-job-log-http")]
1281fn resolve_job_log_target(target: &str, api_path: bool) -> Result<String, GithubJobLogHttpError> {
1282    if api_path {
1283        if !target.starts_with("/repos/")
1284            || target.starts_with("//")
1285            || !target.is_ascii()
1286            || target.bytes().any(|byte| {
1287                !byte.is_ascii_alphanumeric()
1288                    && !matches!(byte, b'/' | b'?' | b'=' | b'&' | b'_' | b'.' | b'-')
1289            })
1290        {
1291            return Err(GithubJobLogHttpError::Protocol);
1292        }
1293        return Ok(format!("{JOB_LOG_API_ORIGIN}{target}"));
1294    }
1295
1296    let uri = target
1297        .parse::<ureq::http::Uri>()
1298        .map_err(|_| GithubJobLogHttpError::Protocol)?;
1299    if uri.scheme_str() != Some("https")
1300        || uri
1301            .authority()
1302            .is_none_or(|authority| authority.as_str().contains('@'))
1303    {
1304        return Err(GithubJobLogHttpError::Protocol);
1305    }
1306    Ok(target.to_owned())
1307}
1308
1309/// Incremental, deadline-bound ureq response body.
1310#[cfg(feature = "secure-job-log-http")]
1311pub struct UreqGithubJobLogHttpBody {
1312    reader: ureq::BodyReader<'static>,
1313    request_deadline: Instant,
1314}
1315
1316#[cfg(feature = "secure-job-log-http")]
1317impl GithubJobLogHttpBody for UreqGithubJobLogHttpBody {
1318    fn next_chunk(
1319        &mut self,
1320        maximum_bytes: usize,
1321        cancellation: &rustferry_remote::CancellationToken,
1322    ) -> Result<Option<Vec<u8>>, GithubJobLogHttpError> {
1323        read_job_log_body_chunk(
1324            &mut self.reader,
1325            maximum_bytes,
1326            self.request_deadline,
1327            cancellation,
1328        )
1329    }
1330}
1331
1332#[cfg(feature = "secure-job-log-http")]
1333impl GithubJobLogHttpClient for UreqGithubJobLogHttpClient {
1334    type Body = UreqGithubJobLogHttpBody;
1335
1336    fn get(
1337        &mut self,
1338        request: &GithubJobLogHttpRequest,
1339        cancellation: &rustferry_remote::CancellationToken,
1340    ) -> Result<GithubJobLogHttpResponse<Self::Body>, GithubJobLogHttpError> {
1341        let request_deadline = Instant::now()
1342            .checked_add(request.timeout())
1343            .ok_or(GithubJobLogHttpError::TimedOut)?;
1344        check_job_log_http_active(cancellation, request_deadline)?;
1345        let response = self
1346            .prepare_get(
1347                request.target(),
1348                request.is_api_path(),
1349                request.authorization(),
1350                request.accept(),
1351                request.timeout(),
1352            )?
1353            .call();
1354        check_job_log_http_active(cancellation, request_deadline)?;
1355        let response = response.map_err(map_ureq_request_error)?;
1356
1357        let status = response.status().as_u16();
1358        let content_type = selected_job_log_header(response.headers(), "content-type")?;
1359        let location = selected_job_log_header(response.headers(), "location")?;
1360        let (_, body) = response.into_parts();
1361        Ok(GithubJobLogHttpResponse::new(
1362            status,
1363            content_type,
1364            location,
1365            UreqGithubJobLogHttpBody {
1366                reader: body.into_reader(),
1367                request_deadline,
1368            },
1369        ))
1370    }
1371}
1372
1373#[cfg(feature = "secure-job-log-http")]
1374impl GithubWorkflowDispatchHttpClient for UreqGithubJobLogHttpClient {
1375    fn post(
1376        &mut self,
1377        request: &GithubWorkflowDispatchHttpRequest,
1378    ) -> Result<GithubWorkflowDispatchHttpResponse, GithubWorkflowDispatchHttpError> {
1379        if request.body().is_empty() || request.body().len() > MAX_WORKFLOW_DISPATCH_BODY_BYTES {
1380            return Err(GithubWorkflowDispatchHttpError::Protocol);
1381        }
1382        let response = self
1383            .prepare_workflow_dispatch_post(request)?
1384            .send(request.body())
1385            .map_err(map_ureq_workflow_dispatch_error)?;
1386        let status = response.status().as_u16();
1387        let content_type = selected_workflow_dispatch_header(response.headers(), "content-type")?;
1388        let (_, body) = response.into_parts();
1389        let mut reader = body
1390            .into_reader()
1391            .take(u64::try_from(request.response_limit()).unwrap_or(u64::MAX) + 1);
1392        let mut bytes = Vec::with_capacity(request.response_limit().saturating_add(1));
1393        reader
1394            .read_to_end(&mut bytes)
1395            .map_err(|error| map_ureq_workflow_dispatch_body_error(&error))?;
1396        if bytes.len() > request.response_limit() {
1397            bytes.fill(0);
1398            return Err(GithubWorkflowDispatchHttpError::ResponseTooLarge);
1399        }
1400        Ok(GithubWorkflowDispatchHttpResponse::new(
1401            status,
1402            content_type,
1403            bytes,
1404        ))
1405    }
1406}
1407
1408#[cfg(feature = "secure-job-log-http")]
1409fn resolve_workflow_dispatch_target(
1410    endpoint: &str,
1411) -> Result<String, GithubWorkflowDispatchHttpError> {
1412    if !endpoint.starts_with("/repos/")
1413        || endpoint.starts_with("//")
1414        || !endpoint.is_ascii()
1415        || endpoint
1416            .bytes()
1417            .any(|byte| !byte.is_ascii_alphanumeric() && !matches!(byte, b'/' | b'_' | b'.' | b'-'))
1418        || !endpoint.ends_with("/dispatches")
1419    {
1420        return Err(GithubWorkflowDispatchHttpError::Protocol);
1421    }
1422    Ok(format!("{JOB_LOG_API_ORIGIN}{endpoint}"))
1423}
1424
1425#[cfg(feature = "secure-job-log-http")]
1426fn selected_workflow_dispatch_header(
1427    headers: &ureq::http::HeaderMap,
1428    name: &'static str,
1429) -> Result<Option<String>, GithubWorkflowDispatchHttpError> {
1430    let mut values = headers.get_all(name).iter();
1431    let Some(value) = values.next() else {
1432        return Ok(None);
1433    };
1434    if values.next().is_some() {
1435        return Err(GithubWorkflowDispatchHttpError::Protocol);
1436    }
1437    let value = value
1438        .to_str()
1439        .map_err(|_| GithubWorkflowDispatchHttpError::Protocol)?;
1440    if value.len() > MAX_JOB_LOG_RESPONSE_HEADER_BYTES {
1441        return Err(GithubWorkflowDispatchHttpError::Protocol);
1442    }
1443    Ok(Some(value.to_owned()))
1444}
1445
1446#[cfg(feature = "secure-job-log-http")]
1447fn map_ureq_workflow_dispatch_error(error: ureq::Error) -> GithubWorkflowDispatchHttpError {
1448    match error {
1449        ureq::Error::Timeout(_) => GithubWorkflowDispatchHttpError::TimedOut,
1450        ureq::Error::Io(error) if io_error_is_timeout(&error) => {
1451            GithubWorkflowDispatchHttpError::TimedOut
1452        }
1453        ureq::Error::Http(_)
1454        | ureq::Error::BadUri(_)
1455        | ureq::Error::Protocol(_)
1456        | ureq::Error::RedirectFailed
1457        | ureq::Error::BodyExceedsLimit(_)
1458        | ureq::Error::TooManyRedirects
1459        | ureq::Error::RequireHttpsOnly(_)
1460        | ureq::Error::LargeResponseHeader(_, _)
1461        | ureq::Error::StatusCode(_) => GithubWorkflowDispatchHttpError::Protocol,
1462        _ => GithubWorkflowDispatchHttpError::Unavailable,
1463    }
1464}
1465
1466#[cfg(feature = "secure-job-log-http")]
1467fn map_ureq_workflow_dispatch_body_error(error: &io::Error) -> GithubWorkflowDispatchHttpError {
1468    if io_error_is_timeout(error) {
1469        GithubWorkflowDispatchHttpError::TimedOut
1470    } else {
1471        GithubWorkflowDispatchHttpError::BodyRead
1472    }
1473}
1474
1475#[cfg(feature = "secure-job-log-http")]
1476fn check_job_log_http_active(
1477    cancellation: &rustferry_remote::CancellationToken,
1478    deadline: Instant,
1479) -> Result<(), GithubJobLogHttpError> {
1480    if cancellation.is_cancelled() {
1481        Err(GithubJobLogHttpError::Cancelled)
1482    } else if Instant::now() >= deadline {
1483        Err(GithubJobLogHttpError::TimedOut)
1484    } else {
1485        Ok(())
1486    }
1487}
1488
1489#[cfg(feature = "secure-job-log-http")]
1490fn selected_job_log_header(
1491    headers: &ureq::http::HeaderMap,
1492    name: &'static str,
1493) -> Result<Option<String>, GithubJobLogHttpError> {
1494    let mut values = headers.get_all(name).iter();
1495    let Some(value) = values.next() else {
1496        return Ok(None);
1497    };
1498    if values.next().is_some() {
1499        return Err(GithubJobLogHttpError::Protocol);
1500    }
1501    let value = value
1502        .to_str()
1503        .map_err(|_| GithubJobLogHttpError::Protocol)?;
1504    if value.len() > MAX_JOB_LOG_RESPONSE_HEADER_BYTES {
1505        return Err(GithubJobLogHttpError::Protocol);
1506    }
1507    Ok(Some(value.to_owned()))
1508}
1509
1510#[cfg(feature = "secure-job-log-http")]
1511fn read_job_log_body_chunk(
1512    reader: &mut impl Read,
1513    maximum_bytes: usize,
1514    request_deadline: Instant,
1515    cancellation: &rustferry_remote::CancellationToken,
1516) -> Result<Option<Vec<u8>>, GithubJobLogHttpError> {
1517    if cancellation.is_cancelled() {
1518        return Err(GithubJobLogHttpError::Cancelled);
1519    }
1520    if maximum_bytes == 0 || maximum_bytes > MAX_JOB_LOG_HTTP_CHUNK_BYTES {
1521        return Err(GithubJobLogHttpError::Protocol);
1522    }
1523    if Instant::now() >= request_deadline {
1524        return Err(GithubJobLogHttpError::TimedOut);
1525    }
1526    let mut bytes = vec![0_u8; maximum_bytes];
1527    let read = reader.read(&mut bytes);
1528    if cancellation.is_cancelled() {
1529        return Err(GithubJobLogHttpError::Cancelled);
1530    }
1531    if Instant::now() >= request_deadline {
1532        return Err(GithubJobLogHttpError::TimedOut);
1533    }
1534    let read = read.map_err(|error| map_ureq_body_error(&error))?;
1535    if read == 0 {
1536        return Ok(None);
1537    }
1538    bytes.truncate(read);
1539    Ok(Some(bytes))
1540}
1541
1542#[cfg(feature = "secure-job-log-http")]
1543fn map_ureq_request_error(error: ureq::Error) -> GithubJobLogHttpError {
1544    match error {
1545        ureq::Error::Timeout(_) => GithubJobLogHttpError::TimedOut,
1546        ureq::Error::Io(error) if io_error_is_timeout(&error) => GithubJobLogHttpError::TimedOut,
1547        ureq::Error::Http(_)
1548        | ureq::Error::BadUri(_)
1549        | ureq::Error::Protocol(_)
1550        | ureq::Error::RedirectFailed
1551        | ureq::Error::BodyExceedsLimit(_)
1552        | ureq::Error::TooManyRedirects
1553        | ureq::Error::RequireHttpsOnly(_)
1554        | ureq::Error::LargeResponseHeader(_, _)
1555        | ureq::Error::StatusCode(_) => GithubJobLogHttpError::Protocol,
1556        _ => GithubJobLogHttpError::Unavailable,
1557    }
1558}
1559
1560#[cfg(feature = "secure-job-log-http")]
1561fn map_ureq_body_error(error: &io::Error) -> GithubJobLogHttpError {
1562    if io_error_is_timeout(error) {
1563        GithubJobLogHttpError::TimedOut
1564    } else {
1565        GithubJobLogHttpError::BodyRead
1566    }
1567}
1568
1569#[cfg(feature = "secure-job-log-http")]
1570fn io_error_is_timeout(error: &io::Error) -> bool {
1571    matches!(
1572        error.kind(),
1573        io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock
1574    ) || error
1575        .get_ref()
1576        .and_then(|source| source.downcast_ref::<ureq::Error>())
1577        .is_some_and(|source| matches!(source, ureq::Error::Timeout(_)))
1578}
1579
1580fn create_private_gh_state(
1581    neutral_working_directory: &Path,
1582) -> Result<GhPrivateState, TransportConfigError> {
1583    let directory = tempfile::Builder::new()
1584        .prefix("rustferry-gh-")
1585        .tempdir()
1586        .map_err(|_| TransportConfigError::InvalidLocalPath {
1587            field: "private gh state directory",
1588        })?;
1589    let root = validate_private_gh_state_root(directory.path(), neutral_working_directory)?;
1590    Ok(GhPrivateState {
1591        _lifetime: directory,
1592        root,
1593    })
1594}
1595
1596fn validate_private_gh_state_root(
1597    root: &Path,
1598    neutral_working_directory: &Path,
1599) -> Result<PathBuf, TransportConfigError> {
1600    let root = canonical_directory(root, "private gh state directory")?;
1601    let neutral_working_directory =
1602        canonical_directory(neutral_working_directory, "neutral working directory")?;
1603    if root.starts_with(neutral_working_directory) {
1604        return Err(TransportConfigError::InvalidLocalPath {
1605            field: "private gh state directory",
1606        });
1607    }
1608    Ok(root)
1609}
1610
1611/// Parsed authenticated GitHub account.
1612#[derive(Clone, Debug, Eq, PartialEq)]
1613pub struct AuthenticatedUser {
1614    id: u64,
1615    login: String,
1616}
1617
1618/// Validated GitHub API credential principal without overstating identity.
1619#[derive(Clone, Debug, Eq, PartialEq)]
1620pub enum AuthenticatedPrincipal {
1621    /// User credential verified through `GET /user`.
1622    User(AuthenticatedUser),
1623    /// Installation credential whose accessible-repository list contains the
1624    /// exact requested repository.
1625    RepositoryCredential {
1626        /// Stable database ID returned by the installation repository list.
1627        repository_id: u64,
1628    },
1629}
1630
1631impl AuthenticatedPrincipal {
1632    /// Stable human-readable principal label for diagnostics and JSON output.
1633    pub fn label(&self) -> &str {
1634        match self {
1635            Self::User(user) => user.login(),
1636            Self::RepositoryCredential { .. } => "repository-scoped token",
1637        }
1638    }
1639
1640    /// Human login only when the API proved a user credential.
1641    pub fn user_login(&self) -> Option<&str> {
1642        match self {
1643            Self::User(user) => Some(user.login()),
1644            Self::RepositoryCredential { .. } => None,
1645        }
1646    }
1647
1648    /// Exact repository ID independently proven by an installation credential.
1649    pub const fn repository_id(&self) -> Option<u64> {
1650        match self {
1651            Self::User(_) => None,
1652            Self::RepositoryCredential { repository_id } => Some(*repository_id),
1653        }
1654    }
1655}
1656
1657impl AuthenticatedUser {
1658    /// Stable GitHub user database identifier.
1659    pub const fn id(&self) -> u64 {
1660        self.id
1661    }
1662
1663    /// Authenticated GitHub login.
1664    pub fn login(&self) -> &str {
1665        &self.login
1666    }
1667}
1668
1669/// Parsed repository metadata used before dispatch.
1670#[derive(Clone, Debug, Eq, PartialEq)]
1671pub struct RepositoryInfo {
1672    id: u64,
1673    full_name: String,
1674    private: bool,
1675    archived: bool,
1676    disabled: bool,
1677    default_branch: BranchName,
1678}
1679
1680impl RepositoryInfo {
1681    /// Stable repository database identifier.
1682    pub const fn id(&self) -> u64 {
1683        self.id
1684    }
1685
1686    /// Canonical owner/name returned by GitHub.
1687    pub fn full_name(&self) -> &str {
1688        &self.full_name
1689    }
1690
1691    /// Whether repository visibility is private.
1692    pub const fn is_private(&self) -> bool {
1693        self.private
1694    }
1695
1696    /// Whether the repository is archived.
1697    pub const fn is_archived(&self) -> bool {
1698        self.archived
1699    }
1700
1701    /// Whether GitHub has disabled the repository.
1702    pub const fn is_disabled(&self) -> bool {
1703        self.disabled
1704    }
1705
1706    /// Canonical default branch reported by GitHub.
1707    pub fn default_branch(&self) -> &BranchName {
1708        &self.default_branch
1709    }
1710}
1711
1712/// Repository-scoped GitHub Actions availability.
1713#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1714pub struct ActionsPermissions {
1715    enabled: bool,
1716}
1717
1718impl ActionsPermissions {
1719    /// Whether GitHub Actions is enabled for the exact repository.
1720    pub const fn enabled(self) -> bool {
1721        self.enabled
1722    }
1723}
1724
1725/// One active workflow registration bound to its exact repository path.
1726#[derive(Clone, Debug, Eq, PartialEq)]
1727pub struct WorkflowRegistration {
1728    repository: Repository,
1729    id: WorkflowId,
1730    path: String,
1731}
1732
1733impl WorkflowRegistration {
1734    pub(crate) fn restore(
1735        repository: Repository,
1736        id: WorkflowId,
1737        path: impl Into<String>,
1738    ) -> Result<Self, TransportConfigError> {
1739        let path = path.into();
1740        validated_workflow_filename(&path).map_err(|_| TransportConfigError::InvalidFormat {
1741            field: "workflow registration path",
1742        })?;
1743        Ok(Self {
1744            repository,
1745            id,
1746            path,
1747        })
1748    }
1749
1750    /// Exact repository whose API registry returned this workflow.
1751    pub const fn repository(&self) -> &Repository {
1752        &self.repository
1753    }
1754
1755    /// Stable GitHub workflow identifier.
1756    pub const fn id(&self) -> WorkflowId {
1757        self.id
1758    }
1759
1760    /// Exact repository-relative workflow path returned by GitHub.
1761    pub fn path(&self) -> &str {
1762        &self.path
1763    }
1764}
1765
1766/// Validated public inputs for one no-secret workflow dispatch.
1767#[derive(Clone, Eq, PartialEq)]
1768pub struct WorkflowDispatchRequest {
1769    repository: Repository,
1770    workflow: WorkflowRegistration,
1771    temporary_ref: TemporaryGitRef,
1772    operation_id: String,
1773    request_sha256: String,
1774    source_revision: CommitSha,
1775    dispatch_revision: CommitSha,
1776    run_name: String,
1777    body_sha256: String,
1778    body: Vec<u8>,
1779}
1780
1781impl fmt::Debug for WorkflowDispatchRequest {
1782    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1783        formatter
1784            .debug_struct("WorkflowDispatchRequest")
1785            .field("repository", &self.repository)
1786            .field("workflow", &self.workflow)
1787            .field("temporary_ref", &self.temporary_ref)
1788            .field("operation_id", &self.operation_id)
1789            .field("request_sha256", &"<redacted>")
1790            .field("source_revision", &self.source_revision)
1791            .field("dispatch_revision", &self.dispatch_revision)
1792            .field("run_name", &"<redacted>")
1793            .field("body_sha256", &"<redacted>")
1794            .field("body", &"<redacted>")
1795            .field("body_bytes", &self.body.len())
1796            .finish()
1797    }
1798}
1799
1800impl WorkflowDispatchRequest {
1801    /// Bind one exact temporary ref and complete public dispatch input set.
1802    ///
1803    /// # Errors
1804    ///
1805    /// Rejects an unsafe operation identifier, a non-lowercase SHA-256, an
1806    /// operation/ref mismatch, or an unexpectedly large serialized body.
1807    pub fn new(
1808        repository: Repository,
1809        workflow: WorkflowRegistration,
1810        temporary_ref: TemporaryGitRef,
1811        operation_id: impl Into<String>,
1812        request_sha256: impl Into<String>,
1813        source_revision: CommitSha,
1814        dispatch_revision: CommitSha,
1815    ) -> Result<Self, TransportConfigError> {
1816        let operation_id = operation_id.into();
1817        if repository != workflow.repository {
1818            return Err(TransportConfigError::InvalidFormat {
1819                field: "workflow dispatch repository",
1820            });
1821        }
1822        validate_workflow_dispatch_operation(&operation_id)?;
1823        if temporary_ref
1824            .branch()
1825            .as_str()
1826            .rsplit_once('/')
1827            .is_none_or(|(_, suffix)| suffix != operation_id)
1828        {
1829            return Err(TransportConfigError::InvalidFormat {
1830                field: "workflow dispatch operation ref",
1831            });
1832        }
1833        let request_sha256 = request_sha256.into();
1834        validate_lower_sha256("request sha256", &request_sha256)?;
1835        let run_name = canonical_workflow_dispatch_run_name(
1836            &operation_id,
1837            &request_sha256,
1838            &source_revision,
1839            &dispatch_revision,
1840        )?;
1841        let body = serde_json::to_vec(&WorkflowDispatchBody {
1842            git_ref: temporary_ref.branch().as_str(),
1843            inputs: WorkflowDispatchInputs {
1844                operation_id: &operation_id,
1845                request_sha256: &request_sha256,
1846                source_revision: source_revision.as_str(),
1847                dispatch_revision: dispatch_revision.as_str(),
1848            },
1849        })
1850        .map_err(|_| TransportConfigError::InvalidFormat {
1851            field: "workflow dispatch body",
1852        })?;
1853        if body.len() > MAX_WORKFLOW_DISPATCH_BODY_BYTES {
1854            return Err(TransportConfigError::TooLong {
1855                field: "workflow dispatch body",
1856                maximum: MAX_WORKFLOW_DISPATCH_BODY_BYTES,
1857            });
1858        }
1859        let body_sha256 = hex::encode(Sha256::digest(&body));
1860        Ok(Self {
1861            repository,
1862            workflow,
1863            temporary_ref,
1864            operation_id,
1865            request_sha256,
1866            source_revision,
1867            dispatch_revision,
1868            run_name,
1869            body_sha256,
1870            body,
1871        })
1872    }
1873
1874    /// Exact target repository.
1875    pub const fn repository(&self) -> &Repository {
1876        &self.repository
1877    }
1878
1879    /// Active workflow selected before dispatch.
1880    pub const fn workflow(&self) -> &WorkflowRegistration {
1881        &self.workflow
1882    }
1883
1884    /// Exact temporary branch passed as the dispatch ref.
1885    pub const fn temporary_ref(&self) -> &TemporaryGitRef {
1886        &self.temporary_ref
1887    }
1888
1889    /// Provider operation identifier.
1890    pub fn operation_id(&self) -> &str {
1891        &self.operation_id
1892    }
1893
1894    /// Canonical complete request digest.
1895    pub fn request_sha256(&self) -> &str {
1896        &self.request_sha256
1897    }
1898
1899    /// Exact trusted source commit.
1900    pub const fn source_revision(&self) -> &CommitSha {
1901        &self.source_revision
1902    }
1903
1904    /// Exact temporary-ref commit expected as `GITHUB_SHA`.
1905    pub const fn dispatch_revision(&self) -> &CommitSha {
1906        &self.dispatch_revision
1907    }
1908
1909    /// Exact public run title derived from all four dispatch inputs.
1910    pub fn run_name(&self) -> &str {
1911        &self.run_name
1912    }
1913
1914    /// SHA-256 of the canonical no-secret dispatch body and its complete input set.
1915    pub fn body_sha256(&self) -> &str {
1916        &self.body_sha256
1917    }
1918}
1919
1920#[derive(Serialize)]
1921struct WorkflowDispatchBody<'a> {
1922    #[serde(rename = "ref")]
1923    git_ref: &'a str,
1924    inputs: WorkflowDispatchInputs<'a>,
1925}
1926
1927#[derive(Serialize)]
1928struct WorkflowDispatchInputs<'a> {
1929    operation_id: &'a str,
1930    request_sha256: &'a str,
1931    source_revision: &'a str,
1932    dispatch_revision: &'a str,
1933}
1934
1935pub(crate) fn canonical_workflow_dispatch_run_name(
1936    operation_id: &str,
1937    request_sha256: &str,
1938    source_revision: &CommitSha,
1939    dispatch_revision: &CommitSha,
1940) -> Result<String, TransportConfigError> {
1941    validate_workflow_dispatch_operation(operation_id)?;
1942    validate_lower_sha256("request sha256", request_sha256)?;
1943    let run_name = format!(
1944        "{WORKFLOW_DISPATCH_RUN_NAME_PREFIX}{operation_id}|{request_sha256}|{}|{}",
1945        source_revision.as_str(),
1946        dispatch_revision.as_str()
1947    );
1948    if run_name.len() > MAX_WORKFLOW_DISPATCH_RUN_NAME_BYTES {
1949        return Err(TransportConfigError::TooLong {
1950            field: "workflow dispatch run name",
1951            maximum: MAX_WORKFLOW_DISPATCH_RUN_NAME_BYTES,
1952        });
1953    }
1954    Ok(run_name)
1955}
1956
1957/// Bounded direct HTTPS request for one workflow dispatch.
1958#[derive(Clone, Eq, PartialEq)]
1959pub struct GithubWorkflowDispatchHttpRequest {
1960    endpoint: String,
1961    body: Vec<u8>,
1962    timeout: Duration,
1963}
1964
1965impl fmt::Debug for GithubWorkflowDispatchHttpRequest {
1966    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1967        formatter
1968            .debug_struct("GithubWorkflowDispatchHttpRequest")
1969            .field("endpoint", &"<redacted>")
1970            .field("body", &"<redacted>")
1971            .field("body_bytes", &self.body.len())
1972            .field("timeout", &self.timeout)
1973            .finish()
1974    }
1975}
1976
1977impl GithubWorkflowDispatchHttpRequest {
1978    /// Fixed GitHub API path. HTTP adapters must not log this value.
1979    pub fn endpoint(&self) -> &str {
1980        &self.endpoint
1981    }
1982
1983    /// Exact deterministic JSON body. It contains no credential or raw device value.
1984    pub fn body(&self) -> &[u8] {
1985        &self.body
1986    }
1987
1988    /// Required GitHub media type.
1989    pub const fn accept(&self) -> &'static str {
1990        WORKFLOW_DISPATCH_ACCEPT
1991    }
1992
1993    /// Required request content type.
1994    pub const fn content_type(&self) -> &'static str {
1995        WORKFLOW_DISPATCH_CONTENT_TYPE
1996    }
1997
1998    /// Required API contract version.
1999    pub const fn api_version(&self) -> &'static str {
2000        WORKFLOW_DISPATCH_API_VERSION
2001    }
2002
2003    /// Whole-request deadline.
2004    pub const fn timeout(&self) -> Duration {
2005        self.timeout
2006    }
2007
2008    /// Maximum accepted response bytes.
2009    pub const fn response_limit(&self) -> usize {
2010        MAX_WORKFLOW_DISPATCH_RESPONSE_BYTES
2011    }
2012}
2013
2014/// Bounded HTTP result. Debug output never includes response bytes or URLs.
2015#[derive(Clone, Eq, PartialEq)]
2016pub struct GithubWorkflowDispatchHttpResponse {
2017    status: u16,
2018    content_type: Option<String>,
2019    body: Vec<u8>,
2020}
2021
2022impl fmt::Debug for GithubWorkflowDispatchHttpResponse {
2023    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2024        formatter
2025            .debug_struct("GithubWorkflowDispatchHttpResponse")
2026            .field("status", &self.status)
2027            .field("content_type", &self.content_type)
2028            .field("body", &"<redacted>")
2029            .field("body_bytes", &self.body.len())
2030            .finish()
2031    }
2032}
2033
2034impl GithubWorkflowDispatchHttpResponse {
2035    /// Construct one adapter response for strict transport validation.
2036    pub fn new(status: u16, content_type: Option<String>, body: Vec<u8>) -> Self {
2037        Self {
2038            status,
2039            content_type,
2040            body,
2041        }
2042    }
2043}
2044
2045/// Redacted direct-HTTPS dispatch failure.
2046#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2047pub enum GithubWorkflowDispatchHttpError {
2048    /// The fixed API endpoint or HTTP exchange violated the protocol.
2049    Protocol,
2050    /// The request exceeded its deadline.
2051    TimedOut,
2052    /// The response body could not be read.
2053    BodyRead,
2054    /// The response body exceeded the fixed cap.
2055    ResponseTooLarge,
2056    /// The HTTP client or network was unavailable.
2057    Unavailable,
2058}
2059
2060impl fmt::Display for GithubWorkflowDispatchHttpError {
2061    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2062        match self {
2063            Self::Protocol => formatter.write_str("GitHub workflow dispatch protocol failed"),
2064            Self::TimedOut => formatter.write_str("GitHub workflow dispatch timed out"),
2065            Self::BodyRead => formatter.write_str("GitHub workflow dispatch response read failed"),
2066            Self::ResponseTooLarge => {
2067                formatter.write_str("GitHub workflow dispatch response exceeded its limit")
2068            }
2069            Self::Unavailable => formatter.write_str("GitHub workflow dispatch is unavailable"),
2070        }
2071    }
2072}
2073
2074impl Error for GithubWorkflowDispatchHttpError {}
2075
2076/// Injectable no-auto-redirect HTTPS adapter for workflow dispatch.
2077pub trait GithubWorkflowDispatchHttpClient {
2078    /// Send one fixed-origin API request and return a bounded response.
2079    ///
2080    /// # Errors
2081    ///
2082    /// Returns only redacted transport categories; credentials, endpoints,
2083    /// bodies, response URLs, and underlying diagnostics must not surface.
2084    fn post(
2085        &mut self,
2086        request: &GithubWorkflowDispatchHttpRequest,
2087    ) -> Result<GithubWorkflowDispatchHttpResponse, GithubWorkflowDispatchHttpError>;
2088}
2089
2090/// Lazy factory detached from the provider's metadata-transport lock.
2091pub trait GithubWorkflowDispatchHttpClientFactory: Send {
2092    /// Consume the factory and acquire one credential-owning direct HTTPS client.
2093    ///
2094    /// # Errors
2095    ///
2096    /// Returns only redacted authentication or bounded process failures.
2097    fn create(
2098        self: Box<Self>,
2099    ) -> Result<Box<dyn GithubWorkflowDispatchHttpClient + Send>, GhExecutionError>;
2100}
2101
2102/// One request-scoped dispatch attempt detached from the metadata transport lock.
2103#[derive(Clone, Debug, Eq, PartialEq)]
2104pub(crate) struct WorkflowDispatchAttempt {
2105    http_request: GithubWorkflowDispatchHttpRequest,
2106    request: WorkflowDispatchRequest,
2107}
2108
2109impl WorkflowDispatchAttempt {
2110    pub(crate) fn send(
2111        self,
2112        client: &mut (impl GithubWorkflowDispatchHttpClient + ?Sized),
2113    ) -> Result<WorkflowDispatchReceipt, TransportError> {
2114        let response = client.post(&self.http_request)?;
2115        if response.status != 200 {
2116            return Err(TransportError::WorkflowDispatchStatusMismatch);
2117        }
2118        if response.body.len() > MAX_WORKFLOW_DISPATCH_RESPONSE_BYTES
2119            || response.content_type.as_deref().is_none_or(|content_type| {
2120                content_type
2121                    .split_once(';')
2122                    .map_or(content_type, |(media_type, _)| media_type)
2123                    .trim()
2124                    != WORKFLOW_DISPATCH_CONTENT_TYPE
2125            })
2126        {
2127            return Err(TransportError::MalformedResponse {
2128                operation: "workflow dispatch receipt",
2129            });
2130        }
2131        let wire: WorkflowDispatchReceiptWire =
2132            crate::strict_json::decode(&response.body, MAX_WORKFLOW_DISPATCH_RESPONSE_BYTES)
2133                .map_err(|_| TransportError::MalformedResponse {
2134                    operation: "workflow dispatch receipt",
2135                })?;
2136        let run_id =
2137            RunId::new(wire.workflow_run_id).map_err(|_| TransportError::MalformedResponse {
2138                operation: "workflow dispatch receipt",
2139            })?;
2140        if !valid_workflow_dispatch_receipt_url(
2141            &wire.run_url,
2142            "https://api.github.com/repos/",
2143            &self.request.repository,
2144            run_id,
2145        ) || !valid_workflow_dispatch_receipt_url(
2146            &wire.html_url,
2147            "https://github.com/",
2148            &self.request.repository,
2149            run_id,
2150        ) {
2151            return Err(TransportError::MalformedResponse {
2152                operation: "workflow dispatch receipt",
2153            });
2154        }
2155        Ok(WorkflowDispatchReceipt {
2156            repository: self.request.repository.clone(),
2157            run_id,
2158            workflow_id: self.request.workflow.id,
2159            workflow_path: self.request.workflow.path.clone(),
2160            dispatch_revision: self.request.dispatch_revision.clone(),
2161            branch: self.request.temporary_ref.branch().clone(),
2162            run_name: self.request.run_name.clone(),
2163        })
2164    }
2165}
2166
2167#[cfg(feature = "secure-job-log-http")]
2168impl GithubWorkflowDispatchHttpClientFactory for GhProcessRunner {
2169    fn create(
2170        self: Box<Self>,
2171    ) -> Result<Box<dyn GithubWorkflowDispatchHttpClient + Send>, GhExecutionError> {
2172        self.github_api_http_client()
2173            .map(|client| Box::new(client) as Box<dyn GithubWorkflowDispatchHttpClient + Send>)
2174    }
2175}
2176
2177/// Secret-free receipt binding one positive run ID to every expected run identity field.
2178#[derive(Clone, Debug, Eq, PartialEq)]
2179pub struct WorkflowDispatchReceipt {
2180    repository: Repository,
2181    run_id: RunId,
2182    workflow_id: WorkflowId,
2183    workflow_path: String,
2184    dispatch_revision: CommitSha,
2185    branch: BranchName,
2186    run_name: String,
2187}
2188
2189impl WorkflowDispatchReceipt {
2190    pub(crate) fn restore(
2191        repository: Repository,
2192        run_id: RunId,
2193        workflow_id: WorkflowId,
2194        workflow_path: impl Into<String>,
2195        dispatch_revision: CommitSha,
2196        branch: BranchName,
2197        run_name: impl Into<String>,
2198    ) -> Result<Self, TransportConfigError> {
2199        let workflow_path = workflow_path.into();
2200        let run_name = run_name.into();
2201        validated_workflow_filename(&workflow_path).map_err(|_| {
2202            TransportConfigError::InvalidFormat {
2203                field: "workflow dispatch receipt path",
2204            }
2205        })?;
2206        if run_name.is_empty() || run_name.len() > MAX_WORKFLOW_DISPATCH_RUN_NAME_BYTES {
2207            return Err(TransportConfigError::InvalidFormat {
2208                field: "workflow dispatch receipt run name",
2209            });
2210        }
2211        Ok(Self {
2212            repository,
2213            run_id,
2214            workflow_id,
2215            workflow_path,
2216            dispatch_revision,
2217            branch,
2218            run_name,
2219        })
2220    }
2221
2222    /// Exact repository that accepted the dispatch.
2223    pub const fn repository(&self) -> &Repository {
2224        &self.repository
2225    }
2226
2227    /// Positive run identifier returned by the 2026-03-10 API.
2228    pub const fn run_id(&self) -> RunId {
2229        self.run_id
2230    }
2231
2232    /// Workflow registration used for the dispatch.
2233    pub const fn workflow_id(&self) -> WorkflowId {
2234        self.workflow_id
2235    }
2236
2237    /// Exact registered workflow path.
2238    pub fn workflow_path(&self) -> &str {
2239        &self.workflow_path
2240    }
2241
2242    /// Exact commit expected from the run metadata and worker `GITHUB_SHA`.
2243    pub const fn dispatch_revision(&self) -> &CommitSha {
2244        &self.dispatch_revision
2245    }
2246
2247    /// Exact temporary branch expected from run metadata and worker `GITHUB_REF`.
2248    pub const fn branch(&self) -> &BranchName {
2249        &self.branch
2250    }
2251
2252    /// Exact public run title binding all four dispatch inputs.
2253    pub fn run_name(&self) -> &str {
2254        &self.run_name
2255    }
2256}
2257
2258#[derive(Deserialize)]
2259#[serde(deny_unknown_fields)]
2260struct WorkflowDispatchReceiptWire {
2261    workflow_run_id: u64,
2262    run_url: String,
2263    html_url: String,
2264}
2265
2266/// Security-relevant metadata for one exact GitHub Environment.
2267#[derive(Clone, Debug, Eq, PartialEq)]
2268pub struct EnvironmentInfo {
2269    name: String,
2270    protected_branches: bool,
2271    custom_branch_policies: bool,
2272    has_required_reviewers: bool,
2273}
2274
2275impl EnvironmentInfo {
2276    /// Exact environment name returned by GitHub.
2277    pub fn name(&self) -> &str {
2278        &self.name
2279    }
2280
2281    /// Whether deployments are limited to repository-protected branches.
2282    pub const fn protected_branches(&self) -> bool {
2283        self.protected_branches
2284    }
2285
2286    /// Whether deployments require a custom branch-policy match.
2287    pub const fn custom_branch_policies(&self) -> bool {
2288        self.custom_branch_policies
2289    }
2290
2291    /// Whether at least one required deployment reviewer is configured.
2292    pub const fn has_required_reviewers(&self) -> bool {
2293        self.has_required_reviewers
2294    }
2295}
2296
2297/// One bounded custom deployment branch policy.
2298#[derive(Clone, Debug, Eq, PartialEq)]
2299pub struct DeploymentBranchPolicy {
2300    id: u64,
2301    name: String,
2302}
2303
2304impl DeploymentBranchPolicy {
2305    /// Stable GitHub branch-policy identifier.
2306    pub const fn id(&self) -> u64 {
2307        self.id
2308    }
2309
2310    /// Exact branch name pattern returned by GitHub.
2311    pub fn name(&self) -> &str {
2312        &self.name
2313    }
2314}
2315
2316/// Metadata for one environment secret; secret values are never readable.
2317#[derive(Clone, Debug, Eq, PartialEq)]
2318pub struct EnvironmentSecret {
2319    name: SecretName,
2320}
2321
2322impl EnvironmentSecret {
2323    /// Exact configured secret name.
2324    pub fn name(&self) -> &SecretName {
2325        &self.name
2326    }
2327}
2328
2329/// Current GitHub Actions run state.
2330#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2331pub enum RunStatus {
2332    /// Run was requested but has not entered the queue.
2333    Requested,
2334    /// Run is queued.
2335    Queued,
2336    /// Run is pending.
2337    Pending,
2338    /// Run awaits an external gate such as environment approval.
2339    Waiting,
2340    /// Run is executing.
2341    InProgress,
2342    /// Run reached a terminal conclusion.
2343    Completed,
2344}
2345
2346impl RunStatus {
2347    fn parse(value: &str) -> Option<Self> {
2348        match value {
2349            "requested" => Some(Self::Requested),
2350            "queued" => Some(Self::Queued),
2351            "pending" => Some(Self::Pending),
2352            "waiting" => Some(Self::Waiting),
2353            "in_progress" => Some(Self::InProgress),
2354            "completed" => Some(Self::Completed),
2355            _ => None,
2356        }
2357    }
2358
2359    /// Whether GitHub considers the run terminal.
2360    pub const fn is_terminal(self) -> bool {
2361        matches!(self, Self::Completed)
2362    }
2363}
2364
2365/// Terminal GitHub Actions run conclusion.
2366#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2367pub enum RunConclusion {
2368    /// All required jobs succeeded.
2369    Success,
2370    /// At least one required job failed.
2371    Failure,
2372    /// The run was cancelled.
2373    Cancelled,
2374    /// The run exceeded a GitHub timeout.
2375    TimedOut,
2376    /// The run completed without a pass/fail result.
2377    Neutral,
2378    /// The run was skipped.
2379    Skipped,
2380    /// GitHub marked the run stale.
2381    Stale,
2382    /// The run requires action outside its jobs.
2383    ActionRequired,
2384    /// GitHub rejected startup before executing jobs.
2385    StartupFailure,
2386}
2387
2388impl RunConclusion {
2389    fn parse(value: &str) -> Option<Self> {
2390        match value {
2391            "success" => Some(Self::Success),
2392            "failure" => Some(Self::Failure),
2393            "cancelled" => Some(Self::Cancelled),
2394            "timed_out" => Some(Self::TimedOut),
2395            "neutral" => Some(Self::Neutral),
2396            "skipped" => Some(Self::Skipped),
2397            "stale" => Some(Self::Stale),
2398            "action_required" => Some(Self::ActionRequired),
2399            "startup_failure" => Some(Self::StartupFailure),
2400            _ => None,
2401        }
2402    }
2403}
2404
2405/// Exact identity selected during run lookup.
2406#[derive(Clone, Debug, Eq, PartialEq)]
2407pub struct RunHandle {
2408    id: RunId,
2409    workflow_id: WorkflowId,
2410    workflow_path: String,
2411    head_sha: CommitSha,
2412    branch: BranchName,
2413    event: RunEvent,
2414}
2415
2416impl RunHandle {
2417    /// Reconstruct one already validated durable run identity inside this crate.
2418    pub(crate) fn restore(
2419        id: u64,
2420        workflow_id: u64,
2421        workflow_path: String,
2422        head_sha: String,
2423        branch: String,
2424        event: RunEvent,
2425    ) -> Result<Self, TransportConfigError> {
2426        validated_workflow_filename(&workflow_path).map_err(|_| {
2427            TransportConfigError::InvalidFormat {
2428                field: "workflow path",
2429            }
2430        })?;
2431        Ok(Self {
2432            id: RunId::new(id)?,
2433            workflow_id: WorkflowId::new(workflow_id)?,
2434            workflow_path,
2435            head_sha: CommitSha::new(head_sha)?,
2436            branch: BranchName::new(branch)?,
2437            event,
2438        })
2439    }
2440
2441    /// Stable run identifier.
2442    pub const fn id(&self) -> RunId {
2443        self.id
2444    }
2445
2446    /// Workflow owning the run.
2447    pub const fn workflow_id(&self) -> WorkflowId {
2448        self.workflow_id
2449    }
2450
2451    /// Repository-relative workflow file path returned by GitHub.
2452    pub fn workflow_path(&self) -> &str {
2453        &self.workflow_path
2454    }
2455
2456    /// Exact head commit.
2457    pub fn head_sha(&self) -> &CommitSha {
2458        &self.head_sha
2459    }
2460
2461    /// Exact temporary branch.
2462    pub fn branch(&self) -> &BranchName {
2463        &self.branch
2464    }
2465
2466    /// Trigger event.
2467    pub const fn event(&self) -> RunEvent {
2468        self.event
2469    }
2470}
2471
2472/// One locally identity-checked run observation.
2473#[derive(Clone, Debug, Eq, PartialEq)]
2474pub struct RunSnapshot {
2475    handle: RunHandle,
2476    run_number: u64,
2477    run_attempt: u64,
2478    status: RunStatus,
2479    conclusion: Option<RunConclusion>,
2480}
2481
2482#[derive(Clone, Debug, Eq, PartialEq)]
2483struct WorkflowDispatchRunSnapshot {
2484    snapshot: RunSnapshot,
2485    run_name: String,
2486}
2487
2488impl RunSnapshot {
2489    /// Reconstruct one validated durable observation inside this crate.
2490    pub(crate) fn restore(
2491        handle: RunHandle,
2492        run_number: u64,
2493        run_attempt: u64,
2494        status: RunStatus,
2495        conclusion: Option<RunConclusion>,
2496    ) -> Result<Self, TransportConfigError> {
2497        if run_number == 0 || run_attempt == 0 || (status.is_terminal() != conclusion.is_some()) {
2498            return Err(TransportConfigError::InvalidFormat {
2499                field: "run snapshot",
2500            });
2501        }
2502        Ok(Self {
2503            handle,
2504            run_number,
2505            run_attempt,
2506            status,
2507            conclusion,
2508        })
2509    }
2510
2511    /// Exact immutable run identity.
2512    pub fn handle(&self) -> &RunHandle {
2513        &self.handle
2514    }
2515
2516    /// Repository-local run number.
2517    pub const fn run_number(&self) -> u64 {
2518        self.run_number
2519    }
2520
2521    /// Current attempt number.
2522    pub const fn run_attempt(&self) -> u64 {
2523        self.run_attempt
2524    }
2525
2526    /// Current status.
2527    pub const fn status(&self) -> RunStatus {
2528        self.status
2529    }
2530
2531    /// Terminal conclusion, present only for completed runs.
2532    pub const fn conclusion(&self) -> Option<RunConclusion> {
2533        self.conclusion
2534    }
2535}
2536
2537/// Bounded artifact metadata returned by one run.
2538#[derive(Clone, Debug, Eq, PartialEq)]
2539pub struct ArtifactInfo {
2540    id: ArtifactId,
2541    name: String,
2542    size_bytes: u64,
2543    expired: bool,
2544    digest: Option<String>,
2545}
2546
2547impl ArtifactInfo {
2548    /// Stable artifact identifier.
2549    pub const fn id(&self) -> ArtifactId {
2550        self.id
2551    }
2552
2553    /// Exact GitHub artifact name.
2554    pub fn name(&self) -> &str {
2555        &self.name
2556    }
2557
2558    /// Size reported by the GitHub API.
2559    pub const fn size_bytes(&self) -> u64 {
2560        self.size_bytes
2561    }
2562
2563    /// Whether GitHub has expired the artifact.
2564    pub const fn is_expired(&self) -> bool {
2565        self.expired
2566    }
2567
2568    /// Optional lowercase `sha256:<hex>` digest reported by GitHub.
2569    pub fn digest(&self) -> Option<&str> {
2570        self.digest.as_deref()
2571    }
2572}
2573
2574/// Canonical download directory plus a safe ZIP basename.
2575#[derive(Clone, Debug, Eq, PartialEq)]
2576pub struct ArtifactDownloadTarget {
2577    directory: PathBuf,
2578    filename: String,
2579}
2580
2581impl ArtifactDownloadTarget {
2582    /// Validate a pre-existing destination directory and new ZIP basename.
2583    ///
2584    /// # Errors
2585    ///
2586    /// Rejects non-canonical directories, paths in the basename, traversal,
2587    /// and names not ending in `.zip`.
2588    #[allow(clippy::case_sensitive_file_extension_comparisons)]
2589    pub fn new(
2590        directory: impl AsRef<Path>,
2591        filename: impl Into<String>,
2592    ) -> Result<Self, TransportConfigError> {
2593        let directory = canonical_directory(directory.as_ref(), "artifact directory")?;
2594        let filename = filename.into();
2595        validate_nonempty_length("artifact filename", &filename, 128)?;
2596        validate_ascii_allowlist("artifact filename", &filename, |byte| {
2597            byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')
2598        })?;
2599        if !filename.as_bytes()[0].is_ascii_alphanumeric()
2600            || !filename.ends_with(".zip")
2601            || filename.contains("..")
2602        {
2603            return Err(TransportConfigError::InvalidFormat {
2604                field: "artifact filename",
2605            });
2606        }
2607        Ok(Self {
2608            directory,
2609            filename,
2610        })
2611    }
2612
2613    /// Final absolute path. The file must not exist before download.
2614    pub fn path(&self) -> PathBuf {
2615        self.directory.join(&self.filename)
2616    }
2617}
2618
2619/// Completed bounded artifact download.
2620#[derive(Clone, Debug, Eq, PartialEq)]
2621pub struct DownloadedArtifact {
2622    path: PathBuf,
2623    bytes: u64,
2624}
2625
2626impl DownloadedArtifact {
2627    /// Absolute ZIP path.
2628    pub fn path(&self) -> &Path {
2629        &self.path
2630    }
2631
2632    /// Exact downloaded byte count.
2633    pub const fn bytes(&self) -> u64 {
2634        self.bytes
2635    }
2636}
2637
2638/// Redacted transport or response-validation failure.
2639#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2640pub enum TransportError {
2641    /// The underlying fixed-argv request failed.
2642    Execution(GhExecutionError),
2643    /// The direct no-auto-redirect workflow-dispatch exchange failed.
2644    WorkflowDispatchHttp(GithubWorkflowDispatchHttpError),
2645    /// One Environment-secret value exceeded GitHub's fixed 48 KiB limit.
2646    EnvironmentSecretTooLarge,
2647    /// GitHub returned malformed or unsupported metadata.
2648    MalformedResponse {
2649        /// Stable operation name; response bytes are intentionally omitted.
2650        operation: &'static str,
2651    },
2652    /// Repository metadata did not match the exact requested owner/name.
2653    RepositoryIdentityMismatch,
2654    /// An installation credential did not include the exact requested repository.
2655    RepositoryAuthorizationMissing,
2656    /// Environment metadata did not match the exact configured name.
2657    EnvironmentIdentityMismatch,
2658    /// Workflow path was not one validated `.github/workflows/*.yml` file.
2659    InvalidWorkflowPath,
2660    /// Workflow metadata did not match the exact requested path.
2661    WorkflowIdentityMismatch,
2662    /// The exact registered workflow is not active.
2663    WorkflowInactive,
2664    /// Workflow dispatch did not return the required HTTP 200 response.
2665    WorkflowDispatchStatusMismatch,
2666    /// No exact matching run was found within the page bound.
2667    RunNotFound,
2668    /// More than one exact matching run was returned.
2669    AmbiguousRun,
2670    /// A known run changed immutable identity fields.
2671    RunIdentityMismatch,
2672    /// Polling ended before the run became terminal.
2673    PollLimitReached,
2674    /// Pagination filled every allowed page, so results may be incomplete.
2675    PaginationLimitReached {
2676        /// Stable resource kind.
2677        resource: &'static str,
2678    },
2679    /// No exact artifact name matched.
2680    ArtifactNotFound,
2681    /// More than one artifact had the exact expected name.
2682    AmbiguousArtifact,
2683    /// The selected artifact has expired.
2684    ArtifactExpired,
2685    /// Artifact metadata or response exceeded the configured size bound.
2686    ArtifactTooLarge,
2687    /// Downloaded bytes were not structurally recognizable as one ZIP file.
2688    InvalidArtifactZip,
2689    /// Downloaded byte count differs from the exact GitHub metadata.
2690    ArtifactSizeMismatch,
2691    /// Downloaded SHA-256 differs from the exact GitHub metadata.
2692    ArtifactDigestMismatch,
2693    /// The exact destination already exists; it is never overwritten.
2694    DestinationExists,
2695    /// Creating, writing, syncing, or verifying the new destination failed.
2696    ArtifactWriteFailed,
2697    /// A failed create, write, or verification may have left the new destination on disk.
2698    ArtifactCleanupUncertain,
2699}
2700
2701impl fmt::Display for TransportError {
2702    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2703        match self {
2704            Self::Execution(error) => error.fmt(formatter),
2705            Self::WorkflowDispatchHttp(error) => error.fmt(formatter),
2706            Self::EnvironmentSecretTooLarge => {
2707                formatter.write_str("GitHub Environment secret exceeds size limit")
2708            }
2709            Self::MalformedResponse { operation } => {
2710                write!(formatter, "GitHub returned malformed {operation} metadata")
2711            }
2712            Self::RepositoryIdentityMismatch => {
2713                formatter.write_str("GitHub returned a different repository identity")
2714            }
2715            Self::RepositoryAuthorizationMissing => formatter.write_str(
2716                "the GitHub installation credential does not authorize the exact repository",
2717            ),
2718            Self::EnvironmentIdentityMismatch => {
2719                formatter.write_str("GitHub returned a different environment identity")
2720            }
2721            Self::InvalidWorkflowPath => formatter.write_str("workflow path is invalid"),
2722            Self::WorkflowIdentityMismatch => {
2723                formatter.write_str("GitHub returned a different workflow identity")
2724            }
2725            Self::WorkflowInactive => formatter.write_str("GitHub workflow is not active"),
2726            Self::WorkflowDispatchStatusMismatch => {
2727                formatter.write_str("GitHub workflow dispatch returned an unexpected status")
2728            }
2729            Self::RunNotFound => formatter.write_str("matching GitHub Actions run was not found"),
2730            Self::AmbiguousRun => {
2731                formatter.write_str("multiple GitHub Actions runs matched exact identity")
2732            }
2733            Self::RunIdentityMismatch => formatter.write_str("GitHub Actions run identity changed"),
2734            Self::PollLimitReached => {
2735                formatter.write_str("GitHub Actions polling limit was reached")
2736            }
2737            Self::PaginationLimitReached { resource } => {
2738                write!(formatter, "GitHub {resource} pagination limit was reached")
2739            }
2740            Self::ArtifactNotFound => formatter.write_str("matching artifact was not found"),
2741            Self::AmbiguousArtifact => {
2742                formatter.write_str("multiple artifacts had the expected name")
2743            }
2744            Self::ArtifactExpired => formatter.write_str("GitHub artifact has expired"),
2745            Self::ArtifactTooLarge => formatter.write_str("GitHub artifact exceeds size limit"),
2746            Self::InvalidArtifactZip => {
2747                formatter.write_str("GitHub artifact response is not a valid ZIP envelope")
2748            }
2749            Self::ArtifactSizeMismatch => {
2750                formatter.write_str("downloaded artifact size differs from GitHub metadata")
2751            }
2752            Self::ArtifactDigestMismatch => {
2753                formatter.write_str("downloaded artifact digest differs from GitHub metadata")
2754            }
2755            Self::DestinationExists => formatter.write_str("artifact destination already exists"),
2756            Self::ArtifactWriteFailed => formatter.write_str("artifact write failed"),
2757            Self::ArtifactCleanupUncertain => {
2758                formatter.write_str("artifact cleanup could not be confirmed")
2759            }
2760        }
2761    }
2762}
2763
2764impl Error for TransportError {}
2765
2766impl From<GhExecutionError> for TransportError {
2767    fn from(value: GhExecutionError) -> Self {
2768        Self::Execution(value)
2769    }
2770}
2771
2772impl From<GithubWorkflowDispatchHttpError> for TransportError {
2773    fn from(value: GithubWorkflowDispatchHttpError) -> Self {
2774        Self::WorkflowDispatchHttp(value)
2775    }
2776}
2777
2778/// Sleeper injection used to test bounded polling without wall-clock delay.
2779pub trait PollSleeper {
2780    /// Wait once between two API polls.
2781    fn sleep(&mut self, duration: Duration);
2782}
2783
2784/// Standard thread-based polling sleeper.
2785#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
2786pub struct ThreadPollSleeper;
2787
2788impl PollSleeper for ThreadPollSleeper {
2789    fn sleep(&mut self, duration: Duration) {
2790        thread::sleep(duration);
2791    }
2792}
2793
2794/// GitHub.com transport over an injectable `gh api` runner.
2795#[derive(Clone, Debug, Eq, PartialEq)]
2796pub struct GithubTransport<R> {
2797    runner: R,
2798    limits: TransportLimits,
2799}
2800
2801impl<R> GithubTransport<R> {
2802    /// Bind a runner to explicit bounded transport limits.
2803    pub const fn new(runner: R, limits: TransportLimits) -> Self {
2804        Self { runner, limits }
2805    }
2806
2807    /// Recover the runner, including captured requests in test adapters.
2808    pub fn into_runner(self) -> R {
2809        self.runner
2810    }
2811
2812    /// Borrow the runner for adapter-specific inspection.
2813    pub fn runner(&self) -> &R {
2814        &self.runner
2815    }
2816
2817    /// Dispatch one active registered workflow through a direct, no-redirect
2818    /// HTTPS adapter and parse the strict 2026-03-10 receipt.
2819    ///
2820    /// # Errors
2821    ///
2822    /// Requires HTTP 200, JSON content, an exact bounded response object, and
2823    /// a positive workflow run identifier. Response URLs are validated then
2824    /// discarded and never enter the returned receipt or diagnostics.
2825    pub fn dispatch_workflow(
2826        &self,
2827        client: &mut (impl GithubWorkflowDispatchHttpClient + ?Sized),
2828        request: &WorkflowDispatchRequest,
2829    ) -> Result<WorkflowDispatchReceipt, TransportError> {
2830        self.workflow_dispatch_attempt(request).send(client)
2831    }
2832
2833    pub(crate) fn workflow_dispatch_attempt(
2834        &self,
2835        request: &WorkflowDispatchRequest,
2836    ) -> WorkflowDispatchAttempt {
2837        WorkflowDispatchAttempt {
2838            http_request: GithubWorkflowDispatchHttpRequest {
2839                endpoint: request.repository.endpoint(&format!(
2840                    "/actions/workflows/{}/dispatches",
2841                    request.workflow.id.get()
2842                )),
2843                body: request.body.clone(),
2844                timeout: self.limits.api_timeout,
2845            },
2846            request: request.clone(),
2847        }
2848    }
2849}
2850
2851impl<R: GhSecretRunner> GithubTransport<R> {
2852    /// Set one bounded GitHub Environment secret through fixed argv and stdin.
2853    ///
2854    /// # Errors
2855    ///
2856    /// Rejects values above GitHub's 48 KiB limit before invoking the runner.
2857    /// Process failures are redacted and never include the secret value.
2858    pub fn set_environment_secret(
2859        &mut self,
2860        request: &EnvironmentSecretWriteRequest,
2861        value: &SecretBytes,
2862    ) -> Result<EnvironmentSecretWriteReceipt, TransportError> {
2863        if value.len() > MAX_ENVIRONMENT_SECRET_BYTES {
2864            return Err(TransportError::EnvironmentSecretTooLarge);
2865        }
2866        self.runner
2867            .set_environment_secret(request, value, self.limits.api_timeout)?;
2868        Ok(EnvironmentSecretWriteReceipt {
2869            repository: request.repository.clone(),
2870            environment: request.environment.clone(),
2871            name: request.name.clone(),
2872        })
2873    }
2874}
2875
2876impl<R: GhRunner> GithubTransport<R> {
2877    /// Prove the configured credential by API capability, then bind it to the
2878    /// exact repository when it is an installation token.
2879    ///
2880    /// # Errors
2881    ///
2882    /// Returns a redacted execution or response-validation failure. A user
2883    /// credential must satisfy `GET /user`. If that capability is
2884    /// rejected, the credential must satisfy the installation-only accessible
2885    /// repositories endpoint and list the exact target. Environment variable
2886    /// names are never used to infer token type.
2887    pub fn authenticate(
2888        &mut self,
2889        repository: &Repository,
2890    ) -> Result<AuthenticatedPrincipal, TransportError> {
2891        match self.authenticated_user() {
2892            Ok(user) => Ok(AuthenticatedPrincipal::User(user)),
2893            Err(TransportError::Execution(GhExecutionError::CommandFailed { .. })) => {
2894                let repository_id = self.prove_installation_repository_access(repository)?;
2895                Ok(AuthenticatedPrincipal::RepositoryCredential { repository_id })
2896            }
2897            Err(error) => Err(error),
2898        }
2899    }
2900
2901    /// Verify authentication through `GET /user`.
2902    ///
2903    /// # Errors
2904    ///
2905    /// Returns a redacted execution or response-validation failure.
2906    pub fn authenticated_user(&mut self) -> Result<AuthenticatedUser, TransportError> {
2907        let request = self.metadata_request(ApiMethod::Get, "/user".to_owned(), USER_QUERY);
2908        let output = self.runner.execute(&request)?;
2909        let line = parse_single_utf8_line(&output, "authenticated user")?;
2910        let columns = line.split('\t').collect::<Vec<_>>();
2911        if columns.len() != 2 {
2912            return Err(TransportError::MalformedResponse {
2913                operation: "authenticated user",
2914            });
2915        }
2916        let id = columns[0].parse::<u64>().ok().filter(|id| *id != 0).ok_or(
2917            TransportError::MalformedResponse {
2918                operation: "authenticated user",
2919            },
2920        )?;
2921        let login = decode_base64(columns[1])
2922            .and_then(|bytes| String::from_utf8(bytes).ok())
2923            .ok_or(TransportError::MalformedResponse {
2924                operation: "authenticated user",
2925            })?;
2926        validate_owner(&login).map_err(|_| TransportError::MalformedResponse {
2927            operation: "authenticated user",
2928        })?;
2929        Ok(AuthenticatedUser { id, login })
2930    }
2931
2932    /// Fetch and locally bind repository metadata to the exact owner/name.
2933    ///
2934    /// # Errors
2935    ///
2936    /// Rejects malformed metadata and case-insensitive identity mismatches.
2937    pub fn repository(
2938        &mut self,
2939        repository: &Repository,
2940    ) -> Result<RepositoryInfo, TransportError> {
2941        let request =
2942            self.metadata_request(ApiMethod::Get, repository.endpoint(""), REPOSITORY_QUERY);
2943        let output = self.runner.execute(&request)?;
2944        parse_repository(&output, repository)
2945    }
2946
2947    /// Fetch the exact repository's GitHub Actions enablement policy.
2948    ///
2949    /// # Errors
2950    ///
2951    /// Rejects unavailable or malformed repository policy metadata.
2952    pub fn actions_permissions(
2953        &mut self,
2954        repository: &Repository,
2955    ) -> Result<ActionsPermissions, TransportError> {
2956        let request = self.metadata_request(
2957            ApiMethod::Get,
2958            repository.endpoint("/actions/permissions"),
2959            ACTIONS_PERMISSIONS_QUERY,
2960        );
2961        let output = self.runner.execute(&request)?;
2962        parse_actions_permissions(&output)
2963    }
2964
2965    /// Resolve one workflow file through GitHub's registry and require an
2966    /// active registration at the exact repository-relative path.
2967    ///
2968    /// # Errors
2969    ///
2970    /// Rejects an invalid requested path, malformed metadata, path drift, or
2971    /// any state other than `active`.
2972    pub fn workflow_registration(
2973        &mut self,
2974        repository: &Repository,
2975        workflow_path: &str,
2976    ) -> Result<WorkflowRegistration, TransportError> {
2977        let filename = validated_workflow_filename(workflow_path)?;
2978        let endpoint = repository.endpoint(&format!("/actions/workflows/{filename}"));
2979        let request = self.metadata_request_at_version(
2980            ApiMethod::Get,
2981            endpoint,
2982            WORKFLOW_QUERY,
2983            WORKFLOW_DISPATCH_API_VERSION_HEADER,
2984        );
2985        let output = self.runner.execute(&request)?;
2986        parse_workflow_registration(&output, repository, workflow_path)
2987    }
2988
2989    fn prove_installation_repository_access(
2990        &mut self,
2991        repository: &Repository,
2992    ) -> Result<u64, TransportError> {
2993        for page in 1..=self.limits.pages {
2994            let fields = vec![
2995                ("per_page".to_owned(), self.limits.per_page.to_string()),
2996                ("page".to_owned(), page.to_string()),
2997            ];
2998            let request = self.metadata_request_with_fields(
2999                ApiMethod::Get,
3000                "/installation/repositories".to_owned(),
3001                fields,
3002                INSTALLATION_REPOSITORY_QUERY,
3003            );
3004            let output = self.runner.execute(&request)?;
3005            let repositories = parse_installation_repositories(&output)?;
3006            let page_length = repositories.len();
3007            if let Some(candidate) = repositories.iter().find(|candidate| {
3008                candidate
3009                    .repository
3010                    .full_name()
3011                    .eq_ignore_ascii_case(&repository.full_name())
3012            }) {
3013                return Ok(candidate.id);
3014            }
3015            if page_length < usize::from(self.limits.per_page) {
3016                return Err(TransportError::RepositoryAuthorizationMissing);
3017            }
3018        }
3019        Err(TransportError::PaginationLimitReached {
3020            resource: "installation repository",
3021        })
3022    }
3023
3024    /// Fetch and bind one exact protected signing environment.
3025    ///
3026    /// # Errors
3027    ///
3028    /// Rejects malformed policy metadata or an environment identity mismatch.
3029    pub fn environment(
3030        &mut self,
3031        repository: &Repository,
3032        environment: &ProtectedEnvironment,
3033    ) -> Result<EnvironmentInfo, TransportError> {
3034        let endpoint = repository.endpoint(&format!("/environments/{}", environment.as_str()));
3035        let request = self.metadata_request(ApiMethod::Get, endpoint, ENVIRONMENT_QUERY);
3036        let output = self.runner.execute(&request)?;
3037        parse_environment(&output, environment)
3038    }
3039
3040    /// List every custom deployment branch policy within the page bound.
3041    ///
3042    /// # Errors
3043    ///
3044    /// Rejects malformed or duplicate metadata and incomplete pagination.
3045    pub fn list_deployment_branch_policies(
3046        &mut self,
3047        repository: &Repository,
3048        environment: &ProtectedEnvironment,
3049    ) -> Result<Vec<DeploymentBranchPolicy>, TransportError> {
3050        let mut policies = Vec::new();
3051        let mut names = BTreeSet::new();
3052        for page in 1..=self.limits.pages {
3053            let endpoint = repository.endpoint(&format!(
3054                "/environments/{}/deployment-branch-policies",
3055                environment.as_str()
3056            ));
3057            let fields = vec![
3058                ("per_page".to_owned(), self.limits.per_page.to_string()),
3059                ("page".to_owned(), page.to_string()),
3060            ];
3061            let request = self.metadata_request_with_fields(
3062                ApiMethod::Get,
3063                endpoint,
3064                fields,
3065                DEPLOYMENT_BRANCH_POLICY_LIST_QUERY,
3066            );
3067            let output = self.runner.execute(&request)?;
3068            let page_policies = parse_deployment_branch_policies(&output)?;
3069            let page_length = page_policies.len();
3070            for policy in page_policies {
3071                if !names.insert(policy.name.clone()) {
3072                    return Err(TransportError::MalformedResponse {
3073                        operation: "deployment branch policy list",
3074                    });
3075                }
3076                policies.push(policy);
3077            }
3078            if page_length < usize::from(self.limits.per_page) {
3079                return Ok(policies);
3080            }
3081        }
3082        Err(TransportError::PaginationLimitReached {
3083            resource: "deployment branch policy",
3084        })
3085    }
3086
3087    /// List environment-secret names without reading any secret value.
3088    ///
3089    /// # Errors
3090    ///
3091    /// Rejects malformed or duplicate names and incomplete pagination.
3092    pub fn list_environment_secrets(
3093        &mut self,
3094        repository: &Repository,
3095        environment: &ProtectedEnvironment,
3096    ) -> Result<Vec<EnvironmentSecret>, TransportError> {
3097        let mut secrets = Vec::new();
3098        let mut names = BTreeSet::new();
3099        for page in 1..=self.limits.pages {
3100            let endpoint =
3101                repository.endpoint(&format!("/environments/{}/secrets", environment.as_str()));
3102            let fields = vec![
3103                ("per_page".to_owned(), self.limits.per_page.to_string()),
3104                ("page".to_owned(), page.to_string()),
3105            ];
3106            let request = self.metadata_request_with_fields(
3107                ApiMethod::Get,
3108                endpoint,
3109                fields,
3110                ENVIRONMENT_SECRET_LIST_QUERY,
3111            );
3112            let output = self.runner.execute(&request)?;
3113            let page_secrets = parse_environment_secrets(&output)?;
3114            let page_length = page_secrets.len();
3115            for secret in page_secrets {
3116                if !names.insert(secret.name.clone()) {
3117                    return Err(TransportError::MalformedResponse {
3118                        operation: "environment secret list",
3119                    });
3120                }
3121                secrets.push(secret);
3122            }
3123            if page_length < usize::from(self.limits.per_page) {
3124                return Ok(secrets);
3125            }
3126        }
3127        Err(TransportError::PaginationLimitReached {
3128            resource: "environment secret",
3129        })
3130    }
3131
3132    /// Find exactly one workflow run by workflow path, head SHA, branch, and event.
3133    ///
3134    /// # Errors
3135    ///
3136    /// Rejects malformed pages, ambiguity, missing exact matches, and a full
3137    /// final page at the pagination bound.
3138    pub fn find_run(
3139        &mut self,
3140        repository: &Repository,
3141        workflow_path: &str,
3142        head_sha: &CommitSha,
3143        branch: &BranchName,
3144        event: RunEvent,
3145    ) -> Result<RunHandle, TransportError> {
3146        validated_workflow_filename(workflow_path)?;
3147        let mut matching: Option<RunSnapshot> = None;
3148        for page in 1..=self.limits.pages {
3149            let endpoint = repository.endpoint("/actions/runs");
3150            let fields = vec![
3151                ("head_sha".to_owned(), head_sha.as_str().to_owned()),
3152                ("branch".to_owned(), branch.as_str().to_owned()),
3153                ("event".to_owned(), event.as_str().to_owned()),
3154                ("exclude_pull_requests".to_owned(), "true".to_owned()),
3155                ("per_page".to_owned(), self.limits.per_page.to_string()),
3156                ("page".to_owned(), page.to_string()),
3157            ];
3158            let request =
3159                self.metadata_request_with_fields(ApiMethod::Get, endpoint, fields, RUN_LIST_QUERY);
3160            let output = self.runner.execute(&request)?;
3161            let rows = parse_run_rows(&output, "run list")?;
3162            for candidate in &rows {
3163                if workflow_run_path_matches(&candidate.handle.workflow_path, workflow_path, branch)
3164                    && candidate.handle.head_sha == *head_sha
3165                    && candidate.handle.branch == *branch
3166                    && candidate.handle.event == event
3167                {
3168                    let mut candidate = candidate.clone();
3169                    workflow_path.clone_into(&mut candidate.handle.workflow_path);
3170                    match &matching {
3171                        None => matching = Some(candidate),
3172                        Some(previous) if *previous == candidate => {}
3173                        Some(_) => return Err(TransportError::AmbiguousRun),
3174                    }
3175                }
3176            }
3177            if rows.len() < usize::from(self.limits.per_page) {
3178                return matching
3179                    .map(|snapshot| snapshot.handle)
3180                    .ok_or(TransportError::RunNotFound);
3181            }
3182        }
3183        Err(TransportError::PaginationLimitReached { resource: "run" })
3184    }
3185
3186    /// Find exactly one workflow-dispatch run whose public title binds all four inputs.
3187    ///
3188    /// # Errors
3189    ///
3190    /// Rejects malformed pages, missing or ambiguous exact matches, correlation drift, and
3191    /// incomplete pagination.
3192    pub fn find_workflow_dispatch_run(
3193        &mut self,
3194        request: &WorkflowDispatchRequest,
3195    ) -> Result<RunHandle, TransportError> {
3196        let mut matching: Option<RunSnapshot> = None;
3197        for page in 1..=self.limits.pages {
3198            let endpoint = request.repository.endpoint("/actions/runs");
3199            let fields = vec![
3200                (
3201                    "head_sha".to_owned(),
3202                    request.dispatch_revision.as_str().to_owned(),
3203                ),
3204                (
3205                    "branch".to_owned(),
3206                    request.temporary_ref.branch().as_str().to_owned(),
3207                ),
3208                (
3209                    "event".to_owned(),
3210                    RunEvent::WorkflowDispatch.as_str().to_owned(),
3211                ),
3212                ("exclude_pull_requests".to_owned(), "true".to_owned()),
3213                ("per_page".to_owned(), self.limits.per_page.to_string()),
3214                ("page".to_owned(), page.to_string()),
3215            ];
3216            let api_request = self.metadata_request_with_fields(
3217                ApiMethod::Get,
3218                endpoint,
3219                fields,
3220                WORKFLOW_DISPATCH_RUN_LIST_QUERY,
3221            );
3222            let output = self.runner.execute(&api_request)?;
3223            let rows = parse_workflow_dispatch_run_rows(&output, "workflow dispatch run list")?;
3224            for candidate in &rows {
3225                let handle = candidate.snapshot.handle();
3226                if workflow_run_path_matches(
3227                    handle.workflow_path(),
3228                    request.workflow.path(),
3229                    request.temporary_ref.branch(),
3230                ) && handle.workflow_id() == request.workflow.id()
3231                    && handle.head_sha() == request.dispatch_revision()
3232                    && handle.branch() == request.temporary_ref.branch()
3233                    && handle.event() == RunEvent::WorkflowDispatch
3234                    && candidate.run_name == request.run_name
3235                {
3236                    let mut candidate = candidate.snapshot.clone();
3237                    request
3238                        .workflow
3239                        .path()
3240                        .clone_into(&mut candidate.handle.workflow_path);
3241                    match &matching {
3242                        None => matching = Some(candidate),
3243                        Some(previous) if *previous == candidate => {}
3244                        Some(_) => return Err(TransportError::AmbiguousRun),
3245                    }
3246                }
3247            }
3248            if rows.len() < usize::from(self.limits.per_page) {
3249                return matching
3250                    .map(|snapshot| snapshot.handle)
3251                    .ok_or(TransportError::RunNotFound);
3252            }
3253        }
3254        Err(TransportError::PaginationLimitReached { resource: "run" })
3255    }
3256
3257    /// Fetch one run and re-check every immutable identity field locally.
3258    ///
3259    /// # Errors
3260    ///
3261    /// Rejects malformed metadata or any identity drift.
3262    pub fn run(
3263        &mut self,
3264        repository: &Repository,
3265        handle: &RunHandle,
3266    ) -> Result<RunSnapshot, TransportError> {
3267        let endpoint = repository.endpoint(&format!("/actions/runs/{}", handle.id.get()));
3268        let request = self.metadata_request(ApiMethod::Get, endpoint, RUN_QUERY);
3269        let output = self.runner.execute(&request)?;
3270        let mut rows = parse_run_rows(&output, "run")?;
3271        if rows.len() != 1 {
3272            return Err(TransportError::MalformedResponse { operation: "run" });
3273        }
3274        let mut snapshot = rows.remove(0);
3275        if workflow_run_path_matches(
3276            &snapshot.handle.workflow_path,
3277            &handle.workflow_path,
3278            &handle.branch,
3279        ) {
3280            snapshot
3281                .handle
3282                .workflow_path
3283                .clone_from(&handle.workflow_path);
3284        }
3285        if snapshot.handle != *handle {
3286            return Err(TransportError::RunIdentityMismatch);
3287        }
3288        Ok(snapshot)
3289    }
3290
3291    /// Fetch the run ID returned directly by workflow dispatch and re-check
3292    /// workflow ID/path, dispatch commit, temporary branch, and event locally.
3293    ///
3294    /// # Errors
3295    ///
3296    /// Rejects malformed run metadata or any immutable identity mismatch.
3297    pub fn run_by_id(
3298        &mut self,
3299        receipt: &WorkflowDispatchReceipt,
3300    ) -> Result<RunSnapshot, TransportError> {
3301        let endpoint = receipt
3302            .repository
3303            .endpoint(&format!("/actions/runs/{}", receipt.run_id.get()));
3304        let request = self.metadata_request_at_version(
3305            ApiMethod::Get,
3306            endpoint,
3307            WORKFLOW_DISPATCH_RUN_QUERY,
3308            WORKFLOW_DISPATCH_API_VERSION_HEADER,
3309        );
3310        let output = self.runner.execute(&request)?;
3311        let mut rows = parse_workflow_dispatch_run_rows(&output, "workflow dispatch run")?;
3312        if rows.len() != 1 {
3313            return Err(TransportError::MalformedResponse {
3314                operation: "workflow dispatch run",
3315            });
3316        }
3317        let correlated = rows.remove(0);
3318        if correlated.run_name != receipt.run_name {
3319            return Err(TransportError::RunIdentityMismatch);
3320        }
3321        let mut snapshot = correlated.snapshot;
3322        if workflow_run_path_matches(
3323            &snapshot.handle.workflow_path,
3324            &receipt.workflow_path,
3325            &receipt.branch,
3326        ) {
3327            snapshot
3328                .handle
3329                .workflow_path
3330                .clone_from(&receipt.workflow_path);
3331        }
3332        let handle = snapshot.handle();
3333        if handle.id != receipt.run_id
3334            || handle.workflow_id != receipt.workflow_id
3335            || handle.workflow_path != receipt.workflow_path
3336            || handle.head_sha != receipt.dispatch_revision
3337            || handle.branch != receipt.branch
3338            || handle.event != RunEvent::WorkflowDispatch
3339        {
3340            return Err(TransportError::RunIdentityMismatch);
3341        }
3342        Ok(snapshot)
3343    }
3344
3345    /// Poll a known run until completion using the standard thread sleeper.
3346    ///
3347    /// # Errors
3348    ///
3349    /// Returns when a request fails, identity changes, or the polling bound is
3350    /// exhausted.
3351    pub fn wait_for_run(
3352        &mut self,
3353        repository: &Repository,
3354        handle: &RunHandle,
3355        policy: PollPolicy,
3356    ) -> Result<RunSnapshot, TransportError> {
3357        self.wait_for_run_with(repository, handle, policy, &mut ThreadPollSleeper)
3358    }
3359
3360    /// Poll with an injected sleeper for deterministic orchestration tests.
3361    ///
3362    /// # Errors
3363    ///
3364    /// Returns when a request fails, identity changes, or the polling bound is
3365    /// exhausted.
3366    pub fn wait_for_run_with(
3367        &mut self,
3368        repository: &Repository,
3369        handle: &RunHandle,
3370        policy: PollPolicy,
3371        sleeper: &mut impl PollSleeper,
3372    ) -> Result<RunSnapshot, TransportError> {
3373        for attempt in 0..policy.attempts {
3374            let snapshot = self.run(repository, handle)?;
3375            if snapshot.status.is_terminal() {
3376                return Ok(snapshot);
3377            }
3378            if attempt + 1 < policy.attempts {
3379                sleeper.sleep(policy.interval);
3380            }
3381        }
3382        Err(TransportError::PollLimitReached)
3383    }
3384
3385    /// Request cancellation of one exact numeric run.
3386    ///
3387    /// # Errors
3388    ///
3389    /// Returns a redacted API failure. A successful REST acceptance does not
3390    /// imply the asynchronous cancellation has completed.
3391    pub fn cancel_run(
3392        &mut self,
3393        repository: &Repository,
3394        run_id: RunId,
3395    ) -> Result<(), TransportError> {
3396        let endpoint = repository.endpoint(&format!("/actions/runs/{}/cancel", run_id.get()));
3397        let request = self.silent_request(ApiMethod::Post, endpoint);
3398        self.runner.execute(&request)?;
3399        Ok(())
3400    }
3401
3402    /// List all artifacts for one run within the pagination bound.
3403    ///
3404    /// # Errors
3405    ///
3406    /// Rejects malformed metadata and a full final page at the configured
3407    /// pagination limit.
3408    pub fn list_artifacts(
3409        &mut self,
3410        repository: &Repository,
3411        run_id: RunId,
3412    ) -> Result<Vec<ArtifactInfo>, TransportError> {
3413        let mut artifacts = Vec::new();
3414        for page in 1..=self.limits.pages {
3415            let endpoint =
3416                repository.endpoint(&format!("/actions/runs/{}/artifacts", run_id.get()));
3417            let fields = vec![
3418                ("per_page".to_owned(), self.limits.per_page.to_string()),
3419                ("page".to_owned(), page.to_string()),
3420            ];
3421            let request = self.metadata_request_with_fields(
3422                ApiMethod::Get,
3423                endpoint,
3424                fields,
3425                ARTIFACT_LIST_QUERY,
3426            );
3427            let output = self.runner.execute(&request)?;
3428            let page_artifacts = parse_artifacts(&output)?;
3429            let page_length = page_artifacts.len();
3430            for artifact in page_artifacts {
3431                if let Some(previous) = artifacts
3432                    .iter()
3433                    .find(|previous: &&ArtifactInfo| previous.id == artifact.id)
3434                {
3435                    if previous != &artifact {
3436                        return Err(TransportError::MalformedResponse {
3437                            operation: "artifact list",
3438                        });
3439                    }
3440                } else {
3441                    artifacts.push(artifact);
3442                }
3443            }
3444            if page_length < usize::from(self.limits.per_page) {
3445                return Ok(artifacts);
3446            }
3447        }
3448        Err(TransportError::PaginationLimitReached {
3449            resource: "artifact",
3450        })
3451    }
3452
3453    /// Select exactly one non-expired artifact by exact name.
3454    ///
3455    /// # Errors
3456    ///
3457    /// Rejects missing, duplicate, expired, malformed, or oversized artifacts.
3458    pub fn find_artifact(
3459        &mut self,
3460        repository: &Repository,
3461        run_id: RunId,
3462        expected_name: &ArtifactName,
3463    ) -> Result<ArtifactInfo, TransportError> {
3464        let mut matching = self
3465            .list_artifacts(repository, run_id)?
3466            .into_iter()
3467            .filter(|artifact| artifact.name == expected_name.as_str());
3468        let artifact = matching.next().ok_or(TransportError::ArtifactNotFound)?;
3469        if matching.next().is_some() {
3470            return Err(TransportError::AmbiguousArtifact);
3471        }
3472        if artifact.expired {
3473            return Err(TransportError::ArtifactExpired);
3474        }
3475        if artifact.size_bytes > self.limits.artifact_bytes {
3476            return Err(TransportError::ArtifactTooLarge);
3477        }
3478        Ok(artifact)
3479    }
3480
3481    /// Download one exact artifact ZIP without extracting it.
3482    ///
3483    /// The destination is created with no-clobber semantics, a strict private
3484    /// DACL on Windows, and mode 0600 on Unix. Both API metadata and actual
3485    /// stdout are independently bounded.
3486    ///
3487    /// # Errors
3488    ///
3489    /// Rejects expired/oversized metadata, oversized output, non-ZIP bytes,
3490    /// an existing destination, and redacted filesystem failures.
3491    pub fn download_artifact_zip(
3492        &mut self,
3493        repository: &Repository,
3494        artifact: &ArtifactInfo,
3495        target: &ArtifactDownloadTarget,
3496    ) -> Result<DownloadedArtifact, TransportError> {
3497        if artifact.expired {
3498            return Err(TransportError::ArtifactExpired);
3499        }
3500        if artifact.size_bytes > self.limits.artifact_bytes {
3501            return Err(TransportError::ArtifactTooLarge);
3502        }
3503        let output_limit = usize::try_from(self.limits.artifact_bytes)
3504            .map_err(|_| TransportError::ArtifactTooLarge)?;
3505        let endpoint =
3506            repository.endpoint(&format!("/actions/artifacts/{}/zip", artifact.id.get()));
3507        let request = GhRequest {
3508            method: ApiMethod::Get,
3509            endpoint,
3510            fields: Vec::new(),
3511            jq: None,
3512            silent: false,
3513            output_limit,
3514            timeout: self.limits.download_timeout,
3515            api_version: API_VERSION,
3516        };
3517        let bytes = self.runner.execute(&request)?;
3518        if usize_to_u64(bytes.len()) > self.limits.artifact_bytes || !looks_like_zip(&bytes) {
3519            return Err(if usize_to_u64(bytes.len()) > self.limits.artifact_bytes {
3520                TransportError::ArtifactTooLarge
3521            } else {
3522                TransportError::InvalidArtifactZip
3523            });
3524        }
3525        if usize_to_u64(bytes.len()) != artifact.size_bytes {
3526            return Err(TransportError::ArtifactSizeMismatch);
3527        }
3528        if let Some(expected) = artifact.digest.as_deref() {
3529            let actual = format!("sha256:{}", hex::encode(Sha256::digest(&bytes)));
3530            if actual != expected {
3531                return Err(TransportError::ArtifactDigestMismatch);
3532            }
3533        }
3534        write_new_artifact(target, &bytes)?;
3535        Ok(DownloadedArtifact {
3536            path: target.path(),
3537            bytes: usize_to_u64(bytes.len()),
3538        })
3539    }
3540
3541    fn metadata_request(&self, method: ApiMethod, endpoint: String, jq: &'static str) -> GhRequest {
3542        self.metadata_request_with_fields(method, endpoint, Vec::new(), jq)
3543    }
3544
3545    fn metadata_request_at_version(
3546        &self,
3547        method: ApiMethod,
3548        endpoint: String,
3549        jq: &'static str,
3550        api_version: &'static str,
3551    ) -> GhRequest {
3552        GhRequest {
3553            method,
3554            endpoint,
3555            fields: Vec::new(),
3556            jq: Some(jq),
3557            silent: false,
3558            output_limit: self.limits.api_response_bytes,
3559            timeout: self.limits.api_timeout,
3560            api_version,
3561        }
3562    }
3563
3564    fn metadata_request_with_fields(
3565        &self,
3566        method: ApiMethod,
3567        endpoint: String,
3568        fields: Vec<(String, String)>,
3569        jq: &'static str,
3570    ) -> GhRequest {
3571        GhRequest {
3572            method,
3573            endpoint,
3574            fields,
3575            jq: Some(jq),
3576            silent: false,
3577            output_limit: self.limits.api_response_bytes,
3578            timeout: self.limits.api_timeout,
3579            api_version: API_VERSION,
3580        }
3581    }
3582
3583    fn silent_request(&self, method: ApiMethod, endpoint: String) -> GhRequest {
3584        GhRequest {
3585            method,
3586            endpoint,
3587            fields: Vec::new(),
3588            jq: None,
3589            silent: true,
3590            output_limit: 1_024,
3591            timeout: self.limits.api_timeout,
3592            api_version: API_VERSION,
3593        }
3594    }
3595}
3596
3597fn parse_repository(
3598    output: &[u8],
3599    expected: &Repository,
3600) -> Result<RepositoryInfo, TransportError> {
3601    let line = parse_single_utf8_line(output, "repository")?;
3602    let columns = split_columns(line, 6, "repository")?;
3603    let id = parse_nonzero_u64(columns[0], "repository")?;
3604    if !columns[1].eq_ignore_ascii_case(&expected.full_name()) {
3605        return Err(TransportError::RepositoryIdentityMismatch);
3606    }
3607    let private = parse_bool(columns[2], "repository")?;
3608    let archived = parse_bool(columns[3], "repository")?;
3609    let disabled = parse_bool(columns[4], "repository")?;
3610    let default_branch =
3611        BranchName::new(columns[5]).map_err(|_| TransportError::MalformedResponse {
3612            operation: "repository",
3613        })?;
3614    Ok(RepositoryInfo {
3615        id,
3616        full_name: columns[1].to_owned(),
3617        private,
3618        archived,
3619        disabled,
3620        default_branch,
3621    })
3622}
3623
3624fn parse_actions_permissions(output: &[u8]) -> Result<ActionsPermissions, TransportError> {
3625    let operation = "GitHub Actions permissions";
3626    let line = parse_single_utf8_line(output, operation)?;
3627    Ok(ActionsPermissions {
3628        enabled: parse_bool(line, operation)?,
3629    })
3630}
3631
3632fn parse_workflow_registration(
3633    output: &[u8],
3634    repository: &Repository,
3635    expected_path: &str,
3636) -> Result<WorkflowRegistration, TransportError> {
3637    let operation = "workflow registration";
3638    let line = parse_single_utf8_line(output, operation)?;
3639    let columns = split_columns(line, 3, operation)?;
3640    let id = WorkflowId::new(parse_nonzero_u64(columns[0], operation)?)
3641        .map_err(|_| TransportError::MalformedResponse { operation })?;
3642    let path = parse_base64_metadata_name(columns[1], operation, 4_096)?;
3643    if path != expected_path {
3644        return Err(TransportError::WorkflowIdentityMismatch);
3645    }
3646    if columns[2] != "active" {
3647        return Err(TransportError::WorkflowInactive);
3648    }
3649    Ok(WorkflowRegistration {
3650        repository: repository.clone(),
3651        id,
3652        path,
3653    })
3654}
3655
3656#[derive(Clone, Debug, Eq, PartialEq)]
3657struct InstallationRepository {
3658    id: u64,
3659    repository: Repository,
3660}
3661
3662fn parse_installation_repositories(
3663    output: &[u8],
3664) -> Result<Vec<InstallationRepository>, TransportError> {
3665    let operation = "installation repository list";
3666    let text = parse_utf8(output, operation)?;
3667    if text.is_empty() {
3668        return Ok(Vec::new());
3669    }
3670    text.lines()
3671        .map(|line| {
3672            let columns = split_columns(line, 2, operation)?;
3673            let id = parse_nonzero_u64(columns[0], operation)?;
3674            let full_name = parse_base64_metadata_name(columns[1], operation, 256)?;
3675            let (owner, name) = full_name
3676                .split_once('/')
3677                .ok_or(TransportError::MalformedResponse { operation })?;
3678            if name.contains('/') {
3679                return Err(TransportError::MalformedResponse { operation });
3680            }
3681            let repository = Repository::new(owner, name)
3682                .map_err(|_| TransportError::MalformedResponse { operation })?;
3683            Ok(InstallationRepository { id, repository })
3684        })
3685        .collect()
3686}
3687
3688fn parse_environment(
3689    output: &[u8],
3690    expected: &ProtectedEnvironment,
3691) -> Result<EnvironmentInfo, TransportError> {
3692    let operation = "environment";
3693    let line = parse_single_utf8_line(output, operation)?;
3694    let columns = split_columns(line, 4, operation)?;
3695    let name = parse_base64_metadata_name(columns[0], operation, 64)?;
3696    if name != expected.as_str() {
3697        return Err(TransportError::EnvironmentIdentityMismatch);
3698    }
3699    Ok(EnvironmentInfo {
3700        name,
3701        protected_branches: parse_bool(columns[1], operation)?,
3702        custom_branch_policies: parse_bool(columns[2], operation)?,
3703        has_required_reviewers: parse_bool(columns[3], operation)?,
3704    })
3705}
3706
3707fn parse_deployment_branch_policies(
3708    output: &[u8],
3709) -> Result<Vec<DeploymentBranchPolicy>, TransportError> {
3710    let operation = "deployment branch policy list";
3711    let text = parse_utf8(output, operation)?;
3712    if text.is_empty() {
3713        return Ok(Vec::new());
3714    }
3715    text.lines()
3716        .map(|line| {
3717            let columns = split_columns(line, 2, operation)?;
3718            Ok(DeploymentBranchPolicy {
3719                id: parse_nonzero_u64(columns[0], operation)?,
3720                name: parse_base64_metadata_name(
3721                    columns[1],
3722                    operation,
3723                    MAX_DEPLOYMENT_POLICY_NAME_BYTES,
3724                )?,
3725            })
3726        })
3727        .collect()
3728}
3729
3730fn parse_environment_secrets(output: &[u8]) -> Result<Vec<EnvironmentSecret>, TransportError> {
3731    let operation = "environment secret list";
3732    let text = parse_utf8(output, operation)?;
3733    if text.is_empty() {
3734        return Ok(Vec::new());
3735    }
3736    text.lines()
3737        .map(|line| {
3738            let columns = split_columns(line, 1, operation)?;
3739            let name = parse_base64_metadata_name(columns[0], operation, 128)?;
3740            Ok(EnvironmentSecret {
3741                name: SecretName::new(name)
3742                    .map_err(|_| TransportError::MalformedResponse { operation })?,
3743            })
3744        })
3745        .collect()
3746}
3747
3748fn parse_base64_metadata_name(
3749    value: &str,
3750    operation: &'static str,
3751    maximum: usize,
3752) -> Result<String, TransportError> {
3753    let bytes = decode_base64(value).ok_or(TransportError::MalformedResponse { operation })?;
3754    if bytes.is_empty() || bytes.len() > maximum || bytes.iter().any(u8::is_ascii_control) {
3755        return Err(TransportError::MalformedResponse { operation });
3756    }
3757    let name =
3758        String::from_utf8(bytes).map_err(|_| TransportError::MalformedResponse { operation })?;
3759    if name.chars().any(char::is_control) {
3760        return Err(TransportError::MalformedResponse { operation });
3761    }
3762    Ok(name)
3763}
3764
3765fn parse_run_rows(
3766    output: &[u8],
3767    operation: &'static str,
3768) -> Result<Vec<RunSnapshot>, TransportError> {
3769    let text = parse_utf8(output, operation)?;
3770    if text.is_empty() {
3771        return Ok(Vec::new());
3772    }
3773    text.lines()
3774        .map(|line| parse_run_row(line, operation))
3775        .collect()
3776}
3777
3778fn parse_workflow_dispatch_run_rows(
3779    output: &[u8],
3780    operation: &'static str,
3781) -> Result<Vec<WorkflowDispatchRunSnapshot>, TransportError> {
3782    let text = parse_utf8(output, operation)?;
3783    if text.is_empty() {
3784        return Ok(Vec::new());
3785    }
3786    text.lines()
3787        .map(|line| {
3788            let columns = split_columns(line, 11, operation)?;
3789            let run_name = parse_base64_metadata_name(
3790                columns[10],
3791                operation,
3792                MAX_WORKFLOW_DISPATCH_RUN_NAME_BYTES,
3793            )?;
3794            let snapshot = parse_run_row(&columns[..10].join("\t"), operation)?;
3795            Ok(WorkflowDispatchRunSnapshot { snapshot, run_name })
3796        })
3797        .collect()
3798}
3799
3800fn parse_run_row(line: &str, operation: &'static str) -> Result<RunSnapshot, TransportError> {
3801    let columns = split_columns(line, 10, operation)?;
3802    let id = RunId::new(parse_nonzero_u64(columns[0], operation)?)
3803        .map_err(|_| TransportError::MalformedResponse { operation })?;
3804    let workflow_id = WorkflowId::new(parse_nonzero_u64(columns[1], operation)?)
3805        .map_err(|_| TransportError::MalformedResponse { operation })?;
3806    let workflow_path = parse_base64_metadata_name(columns[2], operation, 4_096)?;
3807    let run_number = parse_nonzero_u64(columns[3], operation)?;
3808    let run_attempt = parse_nonzero_u64(columns[4], operation)?;
3809    let head_sha =
3810        CommitSha::new(columns[5]).map_err(|_| TransportError::MalformedResponse { operation })?;
3811    let branch_bytes =
3812        decode_base64(columns[6]).ok_or(TransportError::MalformedResponse { operation })?;
3813    let branch_text = String::from_utf8(branch_bytes)
3814        .map_err(|_| TransportError::MalformedResponse { operation })?;
3815    let branch = BranchName::new(branch_text)
3816        .map_err(|_| TransportError::MalformedResponse { operation })?;
3817    let event =
3818        RunEvent::parse(columns[7]).ok_or(TransportError::MalformedResponse { operation })?;
3819    let status =
3820        RunStatus::parse(columns[8]).ok_or(TransportError::MalformedResponse { operation })?;
3821    let conclusion = if columns[9].is_empty() {
3822        None
3823    } else {
3824        Some(
3825            RunConclusion::parse(columns[9])
3826                .ok_or(TransportError::MalformedResponse { operation })?,
3827        )
3828    };
3829    if status.is_terminal() != conclusion.is_some() {
3830        return Err(TransportError::MalformedResponse { operation });
3831    }
3832    Ok(RunSnapshot {
3833        handle: RunHandle {
3834            id,
3835            workflow_id,
3836            workflow_path,
3837            head_sha,
3838            branch,
3839            event,
3840        },
3841        run_number,
3842        run_attempt,
3843        status,
3844        conclusion,
3845    })
3846}
3847
3848fn workflow_run_path_matches(value: &str, expected_path: &str, branch: &BranchName) -> bool {
3849    value == expected_path
3850        || value
3851            .strip_prefix(expected_path)
3852            .and_then(|suffix| suffix.strip_prefix('@'))
3853            == Some(branch.as_str())
3854}
3855
3856fn parse_artifacts(output: &[u8]) -> Result<Vec<ArtifactInfo>, TransportError> {
3857    let text = parse_utf8(output, "artifact list")?;
3858    if text.is_empty() {
3859        return Ok(Vec::new());
3860    }
3861    text.lines().map(parse_artifact_row).collect()
3862}
3863
3864fn parse_artifact_row(line: &str) -> Result<ArtifactInfo, TransportError> {
3865    let operation = "artifact list";
3866    let columns = split_columns(line, 5, operation)?;
3867    let id = ArtifactId::new(parse_nonzero_u64(columns[0], operation)?)
3868        .map_err(|_| TransportError::MalformedResponse { operation })?;
3869    let name_bytes =
3870        decode_base64(columns[1]).ok_or(TransportError::MalformedResponse { operation })?;
3871    if name_bytes.is_empty()
3872        || name_bytes.len() > 256
3873        || name_bytes
3874            .iter()
3875            .any(|byte| byte.is_ascii_control() || *byte == b'/')
3876    {
3877        return Err(TransportError::MalformedResponse { operation });
3878    }
3879    let name = String::from_utf8(name_bytes)
3880        .map_err(|_| TransportError::MalformedResponse { operation })?;
3881    let size_bytes = columns[2]
3882        .parse::<u64>()
3883        .map_err(|_| TransportError::MalformedResponse { operation })?;
3884    let expired = parse_bool(columns[3], operation)?;
3885    let digest = parse_artifact_digest(columns[4])?;
3886    Ok(ArtifactInfo {
3887        id,
3888        name,
3889        size_bytes,
3890        expired,
3891        digest,
3892    })
3893}
3894
3895fn parse_artifact_digest(value: &str) -> Result<Option<String>, TransportError> {
3896    if value.is_empty() {
3897        return Ok(None);
3898    }
3899    let Some(hex) = value.strip_prefix("sha256:") else {
3900        return Err(TransportError::MalformedResponse {
3901            operation: "artifact list",
3902        });
3903    };
3904    if hex.len() != 64
3905        || !hex.bytes().all(|byte| byte.is_ascii_hexdigit())
3906        || hex.bytes().any(|byte| byte.is_ascii_uppercase())
3907    {
3908        return Err(TransportError::MalformedResponse {
3909            operation: "artifact list",
3910        });
3911    }
3912    Ok(Some(value.to_owned()))
3913}
3914
3915fn parse_single_utf8_line<'a>(
3916    output: &'a [u8],
3917    operation: &'static str,
3918) -> Result<&'a str, TransportError> {
3919    let text = parse_utf8(output, operation)?;
3920    let text = text.strip_suffix('\n').unwrap_or(text);
3921    if text.is_empty() || text.contains('\n') || text.contains('\r') {
3922        return Err(TransportError::MalformedResponse { operation });
3923    }
3924    Ok(text)
3925}
3926
3927fn parse_utf8<'a>(output: &'a [u8], operation: &'static str) -> Result<&'a str, TransportError> {
3928    std::str::from_utf8(output).map_err(|_| TransportError::MalformedResponse { operation })
3929}
3930
3931fn split_columns<'a>(
3932    line: &'a str,
3933    count: usize,
3934    operation: &'static str,
3935) -> Result<Vec<&'a str>, TransportError> {
3936    let columns = line.split('\t').collect::<Vec<_>>();
3937    if columns.len() != count
3938        || columns
3939            .iter()
3940            .any(|column| column.contains('\r') || column.contains('\n'))
3941    {
3942        return Err(TransportError::MalformedResponse { operation });
3943    }
3944    Ok(columns)
3945}
3946
3947fn parse_nonzero_u64(value: &str, operation: &'static str) -> Result<u64, TransportError> {
3948    let parsed = value
3949        .parse::<u64>()
3950        .map_err(|_| TransportError::MalformedResponse { operation })?;
3951    if parsed == 0 {
3952        return Err(TransportError::MalformedResponse { operation });
3953    }
3954    Ok(parsed)
3955}
3956
3957fn parse_bool(value: &str, operation: &'static str) -> Result<bool, TransportError> {
3958    match value {
3959        "true" => Ok(true),
3960        "false" => Ok(false),
3961        _ => Err(TransportError::MalformedResponse { operation }),
3962    }
3963}
3964
3965fn decode_base64(value: &str) -> Option<Vec<u8>> {
3966    if !value.len().is_multiple_of(4) || value.bytes().any(|byte| !is_base64_byte(byte)) {
3967        return None;
3968    }
3969    let mut output = Vec::with_capacity(value.len() / 4 * 3);
3970    let chunks = value.as_bytes().chunks_exact(4);
3971    for (index, chunk) in chunks.enumerate() {
3972        let final_chunk = (index + 1) * 4 == value.len();
3973        let first = base64_value(chunk[0])?;
3974        let second = base64_value(chunk[1])?;
3975        let third_padding = chunk[2] == b'=';
3976        let fourth_padding = chunk[3] == b'=';
3977        if third_padding && !fourth_padding {
3978            return None;
3979        }
3980        if (third_padding || fourth_padding) && !final_chunk {
3981            return None;
3982        }
3983        let third = if third_padding {
3984            0
3985        } else {
3986            base64_value(chunk[2])?
3987        };
3988        let fourth = if fourth_padding {
3989            0
3990        } else {
3991            base64_value(chunk[3])?
3992        };
3993        if (third_padding && second & 0x0f != 0)
3994            || (fourth_padding && !third_padding && third & 0x03 != 0)
3995        {
3996            return None;
3997        }
3998        output.push(first << 2 | second >> 4);
3999        if !third_padding {
4000            output.push(second << 4 | third >> 2);
4001        }
4002        if !fourth_padding {
4003            output.push(third << 6 | fourth);
4004        }
4005    }
4006    Some(output)
4007}
4008
4009fn is_base64_byte(byte: u8) -> bool {
4010    byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/' | b'=')
4011}
4012
4013fn base64_value(byte: u8) -> Option<u8> {
4014    match byte {
4015        b'A'..=b'Z' => Some(byte - b'A'),
4016        b'a'..=b'z' => Some(byte - b'a' + 26),
4017        b'0'..=b'9' => Some(byte - b'0' + 52),
4018        b'+' => Some(62),
4019        b'/' => Some(63),
4020        _ => None,
4021    }
4022}
4023
4024fn looks_like_zip(bytes: &[u8]) -> bool {
4025    if bytes.len() < ZIP_END_RECORD_MINIMUM_BYTES
4026        || !(bytes.starts_with(b"PK\x03\x04") || bytes.starts_with(b"PK\x05\x06"))
4027    {
4028        return false;
4029    }
4030    let search_start = bytes.len().saturating_sub(ZIP_END_RECORD_SEARCH_BYTES);
4031    bytes[search_start..]
4032        .windows(4)
4033        .rposition(|window| window == b"PK\x05\x06")
4034        .is_some()
4035}
4036
4037#[cfg(windows)]
4038fn write_new_artifact(target: &ArtifactDownloadTarget, bytes: &[u8]) -> Result<(), TransportError> {
4039    let path = target.path();
4040    let mut file = create_windows_private_file(&path)
4041        .map_err(|error| map_windows_artifact_create_error(&error))?;
4042    if file
4043        .write_all(bytes)
4044        .and_then(|()| file.sync_all())
4045        .is_err()
4046    {
4047        remove_failed_windows_artifact(file)?;
4048        return Err(TransportError::ArtifactWriteFailed);
4049    }
4050    if verify_windows_private_file_handle(file.as_handle()).is_err() {
4051        remove_failed_windows_artifact(file)?;
4052        return Err(TransportError::ArtifactWriteFailed);
4053    }
4054    Ok(())
4055}
4056
4057#[cfg(windows)]
4058fn map_windows_artifact_create_error(error: &PrivateDirectoryError) -> TransportError {
4059    if error.cleanup_status() == PrivateDirectoryCleanupStatus::Uncertain {
4060        TransportError::ArtifactCleanupUncertain
4061    } else if error.kind() == PrivateDirectoryErrorKind::AlreadyExists {
4062        TransportError::DestinationExists
4063    } else {
4064        TransportError::ArtifactWriteFailed
4065    }
4066}
4067
4068#[cfg(windows)]
4069fn remove_failed_windows_artifact(file: fs::File) -> Result<(), TransportError> {
4070    remove_windows_private_file_handle(file).map_err(|_| TransportError::ArtifactCleanupUncertain)
4071}
4072
4073#[cfg(not(windows))]
4074fn write_new_artifact(target: &ArtifactDownloadTarget, bytes: &[u8]) -> Result<(), TransportError> {
4075    let path = target.path();
4076    let mut options = OpenOptions::new();
4077    options.write(true).create_new(true);
4078    #[cfg(unix)]
4079    options.mode(0o600);
4080    let mut file = options.open(&path).map_err(|error| {
4081        if error.kind() == io::ErrorKind::AlreadyExists {
4082            TransportError::DestinationExists
4083        } else {
4084            TransportError::ArtifactWriteFailed
4085        }
4086    })?;
4087    if file
4088        .write_all(bytes)
4089        .and_then(|()| file.sync_all())
4090        .is_err()
4091    {
4092        drop(file);
4093        if fs::remove_file(&path).is_err() && path.exists() {
4094            return Err(TransportError::ArtifactWriteFailed);
4095        }
4096        return Err(TransportError::ArtifactWriteFailed);
4097    }
4098    Ok(())
4099}
4100
4101fn run_process(
4102    command: Command,
4103    timeout: Duration,
4104    stdout_limit: usize,
4105) -> Result<Vec<u8>, GhExecutionError> {
4106    run_process_inner(
4107        command,
4108        timeout,
4109        stdout_limit,
4110        None,
4111        true,
4112        PROCESS_POLL_INTERVAL,
4113    )
4114}
4115
4116fn run_process_with_secret_input(
4117    command: Command,
4118    timeout: Duration,
4119    stdout_limit: usize,
4120    input: &SecretBytes,
4121) -> Result<(), GhExecutionError> {
4122    run_process_inner(
4123        command,
4124        timeout,
4125        stdout_limit,
4126        Some(input),
4127        false,
4128        PROCESS_POLL_INTERVAL,
4129    )
4130    .map(drop)
4131}
4132
4133fn run_process_inner(
4134    mut command: Command,
4135    timeout: Duration,
4136    stdout_limit: usize,
4137    input: Option<&SecretBytes>,
4138    retain_stdout: bool,
4139    poll_interval: Duration,
4140) -> Result<Vec<u8>, GhExecutionError> {
4141    let mut child = command.spawn().map_err(|_| GhExecutionError::SpawnFailed)?;
4142    let mut process_tree = ProcessTreeGuard::attach(&child).inspect_err(|_| {
4143        terminate_child(&mut child, process_cleanup_deadline());
4144    })?;
4145    let Some(stdout) = child.stdout.take() else {
4146        let cleanup_deadline = process_cleanup_deadline();
4147        terminate_process_tree(&mut child, &mut process_tree, cleanup_deadline);
4148        return Err(GhExecutionError::ProcessIo);
4149    };
4150    let Some(stderr) = child.stderr.take() else {
4151        let cleanup_deadline = process_cleanup_deadline();
4152        terminate_process_tree(&mut child, &mut process_tree, cleanup_deadline);
4153        return Err(GhExecutionError::ProcessIo);
4154    };
4155    let stdin_writer = if let Some(input) = input {
4156        let Some(mut stdin) = child.stdin.take() else {
4157            let cleanup_deadline = process_cleanup_deadline();
4158            terminate_process_tree(&mut child, &mut process_tree, cleanup_deadline);
4159            return Err(GhExecutionError::ProcessIo);
4160        };
4161        // A bounded zeroizing copy lets timeout handling kill `gh` even if its
4162        // stdin reader stalls while the writer thread is active.
4163        let input = SecretBytes::new(input.expose_secret_bytes().to_vec());
4164        Some(spawn_process_worker(move || {
4165            stdin
4166                .write_all(input.expose_secret_bytes())
4167                .map_err(|_| GhExecutionError::ProcessIo)
4168        }))
4169    } else {
4170        None
4171    };
4172    let stdout_reader =
4173        spawn_process_worker(move || read_capped(stdout, stdout_limit, retain_stdout));
4174    let stderr_reader = spawn_process_worker(move || read_capped(stderr, MAX_STDERR_BYTES, false));
4175    let Some(deadline) = Instant::now().checked_add(timeout) else {
4176        terminate_and_discard_process_output(
4177            &mut child,
4178            &mut process_tree,
4179            stdin_writer.as_ref(),
4180            &stdout_reader,
4181            &stderr_reader,
4182        );
4183        return Err(GhExecutionError::TimedOut);
4184    };
4185
4186    let status = match wait_for_process_exit(&mut child, deadline, poll_interval) {
4187        Ok(status) => status,
4188        Err(error) => {
4189            terminate_and_discard_process_output(
4190                &mut child,
4191                &mut process_tree,
4192                stdin_writer.as_ref(),
4193                &stdout_reader,
4194                &stderr_reader,
4195            );
4196            return Err(error);
4197        }
4198    };
4199    // `gh` has exited, but a descendant may still hold one of its inherited
4200    // pipes open. End the complete supervised tree before collecting output.
4201    process_tree.terminate_descendants();
4202    if Instant::now() >= deadline {
4203        terminate_and_discard_process_output(
4204            &mut child,
4205            &mut process_tree,
4206            stdin_writer.as_ref(),
4207            &stdout_reader,
4208            &stderr_reader,
4209        );
4210        return Err(GhExecutionError::TimedOut);
4211    }
4212    let output = collect_process_workers(
4213        stdin_writer.as_ref(),
4214        &stdout_reader,
4215        &stderr_reader,
4216        deadline,
4217    );
4218    if Instant::now() >= deadline {
4219        drop(output);
4220        return Err(GhExecutionError::TimedOut);
4221    }
4222    let (mut stdout, stderr) = output?;
4223    if !status.success() {
4224        return Err(command_failed(status));
4225    }
4226    if stdout.truncated || stderr.truncated {
4227        return Err(GhExecutionError::OutputLimitExceeded);
4228    }
4229    if Instant::now() >= deadline {
4230        return Err(GhExecutionError::TimedOut);
4231    }
4232    Ok(std::mem::take(&mut stdout.bytes))
4233}
4234
4235fn wait_for_process_exit(
4236    child: &mut Child,
4237    deadline: Instant,
4238    poll_interval: Duration,
4239) -> Result<ExitStatus, GhExecutionError> {
4240    debug_assert!(!poll_interval.is_zero());
4241    loop {
4242        if Instant::now() >= deadline {
4243            return Err(GhExecutionError::TimedOut);
4244        }
4245        match child.try_wait() {
4246            Ok(Some(status)) if Instant::now() < deadline => return Ok(status),
4247            Ok(Some(_)) => return Err(GhExecutionError::TimedOut),
4248            Ok(None) => {
4249                let remaining = deadline.saturating_duration_since(Instant::now());
4250                if !remaining.is_zero() {
4251                    thread::sleep(remaining.min(poll_interval));
4252                }
4253            }
4254            Err(_) => return Err(GhExecutionError::ProcessIo),
4255        }
4256    }
4257}
4258
4259fn terminate_and_discard_process_output(
4260    child: &mut Child,
4261    process_tree: &mut ProcessTreeGuard,
4262    stdin_writer: Option<&Receiver<Result<(), GhExecutionError>>>,
4263    stdout_reader: &Receiver<Result<CapturedOutput, GhExecutionError>>,
4264    stderr_reader: &Receiver<Result<CapturedOutput, GhExecutionError>>,
4265) {
4266    // The fresh grace is teardown-only; its output can never determine success.
4267    let cleanup_deadline = process_cleanup_deadline();
4268    terminate_process_tree(child, process_tree, cleanup_deadline);
4269    let _ = collect_process_workers(stdin_writer, stdout_reader, stderr_reader, cleanup_deadline);
4270}
4271
4272fn spawn_process_worker<T>(
4273    worker: impl FnOnce() -> Result<T, GhExecutionError> + Send + 'static,
4274) -> Receiver<Result<T, GhExecutionError>>
4275where
4276    T: Send + 'static,
4277{
4278    let (sender, receiver) = mpsc::sync_channel(1);
4279    // Completion is observed through the bounded receiver; the detached handle
4280    // must never turn a process timeout into an unbounded thread join.
4281    drop(thread::spawn(move || {
4282        let _ = sender.send(worker());
4283    }));
4284    receiver
4285}
4286
4287fn collect_process_workers(
4288    stdin_writer: Option<&Receiver<Result<(), GhExecutionError>>>,
4289    stdout_reader: &Receiver<Result<CapturedOutput, GhExecutionError>>,
4290    stderr_reader: &Receiver<Result<CapturedOutput, GhExecutionError>>,
4291    deadline: Instant,
4292) -> Result<(CapturedOutput, CapturedOutput), GhExecutionError> {
4293    if let Some(stdin_writer) = stdin_writer {
4294        receive_process_worker(stdin_writer, deadline)?;
4295    }
4296    let stdout = receive_process_worker(stdout_reader, deadline)?;
4297    let stderr = receive_process_worker(stderr_reader, deadline)?;
4298    Ok((stdout, stderr))
4299}
4300
4301fn receive_process_worker<T>(
4302    receiver: &Receiver<Result<T, GhExecutionError>>,
4303    deadline: Instant,
4304) -> Result<T, GhExecutionError> {
4305    let remaining = deadline.saturating_duration_since(Instant::now());
4306    match receiver.recv_timeout(remaining) {
4307        Ok(result) => result,
4308        Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => {
4309            Err(GhExecutionError::ProcessIo)
4310        }
4311    }
4312}
4313
4314fn process_cleanup_deadline() -> Instant {
4315    Instant::now()
4316        .checked_add(PROCESS_CLEANUP_GRACE)
4317        .unwrap_or_else(Instant::now)
4318}
4319
4320#[cfg(unix)]
4321fn configure_process_tree(command: &mut Command) {
4322    use std::os::unix::process::CommandExt as _;
4323
4324    command.process_group(0);
4325}
4326
4327#[cfg(not(unix))]
4328fn configure_process_tree(_command: &mut Command) {}
4329
4330struct ProcessTreeGuard {
4331    #[cfg(unix)]
4332    process_group: Option<u32>,
4333    platform_guard: Option<rustferry_core::process_control::ProcessGroupGuard>,
4334}
4335
4336impl ProcessTreeGuard {
4337    fn attach(child: &Child) -> Result<Self, GhExecutionError> {
4338        let platform_guard = Some(
4339            rustferry_core::process_control::track_child(child)
4340                .map_err(|_| GhExecutionError::ProcessIo)?,
4341        );
4342        Ok(Self {
4343            #[cfg(unix)]
4344            process_group: Some(child.id()),
4345            platform_guard,
4346        })
4347    }
4348
4349    fn terminate_descendants(&mut self) {
4350        #[cfg(unix)]
4351        if let Some(process_group) = self.process_group.take() {
4352            let _ = Command::new("/bin/kill")
4353                .args(["-KILL", "--", &format!("-{process_group}")])
4354                .env_clear()
4355                .stdin(Stdio::null())
4356                .stdout(Stdio::null())
4357                .stderr(Stdio::null())
4358                .status();
4359        }
4360        drop(self.platform_guard.take());
4361    }
4362}
4363
4364impl Drop for ProcessTreeGuard {
4365    fn drop(&mut self) {
4366        self.terminate_descendants();
4367    }
4368}
4369
4370fn terminate_process_tree(
4371    child: &mut Child,
4372    process_tree: &mut ProcessTreeGuard,
4373    deadline: Instant,
4374) {
4375    process_tree.terminate_descendants();
4376    terminate_child(child, deadline);
4377}
4378
4379fn terminate_child(child: &mut Child, deadline: Instant) {
4380    let _ = child.kill();
4381    loop {
4382        match child.try_wait() {
4383            Ok(Some(_)) | Err(_) => return,
4384            Ok(None) => {
4385                let remaining = deadline.saturating_duration_since(Instant::now());
4386                if remaining.is_zero() {
4387                    return;
4388                }
4389                thread::sleep(remaining.min(Duration::from_millis(20)));
4390            }
4391        }
4392    }
4393}
4394
4395fn command_failed(status: ExitStatus) -> GhExecutionError {
4396    GhExecutionError::CommandFailed {
4397        exit_code: status.code(),
4398    }
4399}
4400
4401struct CapturedOutput {
4402    bytes: Vec<u8>,
4403    truncated: bool,
4404}
4405
4406impl Drop for CapturedOutput {
4407    fn drop(&mut self) {
4408        self.bytes.fill(0);
4409    }
4410}
4411
4412fn read_capped(
4413    mut reader: impl Read,
4414    limit: usize,
4415    retain: bool,
4416) -> Result<CapturedOutput, GhExecutionError> {
4417    let mut bytes = if retain {
4418        Vec::with_capacity(limit.min(64 * 1024))
4419    } else {
4420        Vec::new()
4421    };
4422    let mut total = 0_usize;
4423    let mut buffer = [0_u8; 16 * 1024];
4424    loop {
4425        let read = reader
4426            .read(&mut buffer)
4427            .map_err(|_| GhExecutionError::ProcessIo)?;
4428        if read == 0 {
4429            break;
4430        }
4431        let retained = limit.saturating_sub(total).min(read);
4432        if retain && retained > 0 {
4433            bytes.extend_from_slice(&buffer[..retained]);
4434        }
4435        total = total.saturating_add(read);
4436    }
4437    Ok(CapturedOutput {
4438        bytes,
4439        truncated: total > limit,
4440    })
4441}
4442
4443fn canonical_file(path: &Path, field: &'static str) -> Result<PathBuf, TransportConfigError> {
4444    if !is_absolute_normal_path(path) {
4445        return Err(TransportConfigError::InvalidLocalPath { field });
4446    }
4447    let metadata =
4448        fs::symlink_metadata(path).map_err(|_| TransportConfigError::InvalidLocalPath { field })?;
4449    if metadata.file_type().is_symlink() {
4450        let canonical =
4451            fs::canonicalize(path).map_err(|_| TransportConfigError::InvalidLocalPath { field })?;
4452        if !canonical.is_file() {
4453            return Err(TransportConfigError::InvalidLocalPath { field });
4454        }
4455        return Ok(canonical);
4456    }
4457    if !metadata.is_file() {
4458        return Err(TransportConfigError::InvalidLocalPath { field });
4459    }
4460    fs::canonicalize(path).map_err(|_| TransportConfigError::InvalidLocalPath { field })
4461}
4462
4463fn executable_basename_matches(path: &Path, stem: &str) -> bool {
4464    let Some(name) = path.file_name().and_then(OsStr::to_str) else {
4465        return false;
4466    };
4467    #[cfg(windows)]
4468    {
4469        name.eq_ignore_ascii_case(&format!("{stem}.exe"))
4470    }
4471    #[cfg(not(windows))]
4472    {
4473        name == stem
4474    }
4475}
4476
4477fn canonical_directory(path: &Path, field: &'static str) -> Result<PathBuf, TransportConfigError> {
4478    if !is_absolute_normal_path(path) {
4479        return Err(TransportConfigError::InvalidLocalPath { field });
4480    }
4481    let metadata =
4482        fs::symlink_metadata(path).map_err(|_| TransportConfigError::InvalidLocalPath { field })?;
4483    if metadata.file_type().is_symlink() || !metadata.is_dir() {
4484        return Err(TransportConfigError::InvalidLocalPath { field });
4485    }
4486    fs::canonicalize(path).map_err(|_| TransportConfigError::InvalidLocalPath { field })
4487}
4488
4489fn is_absolute_normal_path(path: &Path) -> bool {
4490    path.is_absolute()
4491        && path
4492            .components()
4493            .all(|component| !matches!(component, Component::CurDir | Component::ParentDir))
4494}
4495
4496fn validate_owner(value: &str) -> Result<(), TransportConfigError> {
4497    validate_nonempty_length("repository owner", value, 39)?;
4498    validate_ascii_allowlist("repository owner", value, |byte| {
4499        byte.is_ascii_alphanumeric() || byte == b'-'
4500    })?;
4501    if !value.as_bytes()[0].is_ascii_alphanumeric()
4502        || !value.as_bytes()[value.len() - 1].is_ascii_alphanumeric()
4503        || value.contains("--")
4504    {
4505        return Err(TransportConfigError::InvalidFormat {
4506            field: "repository owner",
4507        });
4508    }
4509    Ok(())
4510}
4511
4512fn validate_repository_name(value: &str) -> Result<(), TransportConfigError> {
4513    validate_nonempty_length("repository name", value, 100)?;
4514    validate_ascii_allowlist("repository name", value, |byte| {
4515        byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')
4516    })?;
4517    if value.starts_with('-')
4518        || value == "."
4519        || value == ".."
4520        || value.ends_with('.')
4521        || value.contains("..")
4522    {
4523        return Err(TransportConfigError::InvalidFormat {
4524            field: "repository name",
4525        });
4526    }
4527    Ok(())
4528}
4529
4530#[allow(clippy::case_sensitive_file_extension_comparisons)]
4531fn validate_workflow_dispatch_operation(value: &str) -> Result<(), TransportConfigError> {
4532    validate_nonempty_length("workflow dispatch operation", value, 64)?;
4533    validate_ascii_allowlist("workflow dispatch operation", value, |byte| {
4534        byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')
4535    })?;
4536    if value.starts_with('.')
4537        || value.ends_with('.')
4538        || value.ends_with(".lock")
4539        || value.contains("..")
4540    {
4541        return Err(TransportConfigError::InvalidFormat {
4542            field: "workflow dispatch operation",
4543        });
4544    }
4545    Ok(())
4546}
4547
4548fn validate_lower_sha256(field: &'static str, value: &str) -> Result<(), TransportConfigError> {
4549    if value.len() != 64
4550        || !value
4551            .bytes()
4552            .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
4553    {
4554        return Err(TransportConfigError::InvalidFormat { field });
4555    }
4556    Ok(())
4557}
4558
4559fn valid_workflow_dispatch_receipt_url(
4560    value: &str,
4561    origin: &str,
4562    repository: &Repository,
4563    run_id: RunId,
4564) -> bool {
4565    let expected = format!(
4566        "{origin}{}/{}/actions/runs/{}",
4567        repository.owner(),
4568        repository.name(),
4569        run_id.get()
4570    );
4571    !value.is_empty()
4572        && value.len() <= 2_048
4573        && value.is_ascii()
4574        && !value.bytes().any(|byte| byte.is_ascii_control())
4575        && value.eq_ignore_ascii_case(&expected)
4576}
4577
4578#[allow(clippy::case_sensitive_file_extension_comparisons)]
4579fn validated_workflow_filename(workflow_path: &str) -> Result<&str, TransportError> {
4580    let filename = workflow_path
4581        .strip_prefix(".github/workflows/")
4582        .ok_or(TransportError::InvalidWorkflowPath)?;
4583    if filename.is_empty()
4584        || filename.len() > 128
4585        || !filename.as_bytes()[0].is_ascii_alphanumeric()
4586        || filename.contains("..")
4587        || !(filename.ends_with(".yml") || filename.ends_with(".yaml"))
4588        || !filename
4589            .bytes()
4590            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
4591    {
4592        return Err(TransportError::InvalidWorkflowPath);
4593    }
4594    Ok(filename)
4595}
4596
4597fn validate_nonempty_length(
4598    field: &'static str,
4599    value: &str,
4600    maximum: usize,
4601) -> Result<(), TransportConfigError> {
4602    if value.is_empty() {
4603        return Err(TransportConfigError::Empty { field });
4604    }
4605    if value.len() > maximum {
4606        return Err(TransportConfigError::TooLong { field, maximum });
4607    }
4608    Ok(())
4609}
4610
4611fn validate_ascii_allowlist(
4612    field: &'static str,
4613    value: &str,
4614    allowed: impl Fn(u8) -> bool,
4615) -> Result<(), TransportConfigError> {
4616    if let Some((index, _)) = value.bytes().enumerate().find(|(_, byte)| !allowed(*byte)) {
4617        return Err(TransportConfigError::InvalidCharacter { field, index });
4618    }
4619    Ok(())
4620}
4621
4622fn validate_range(
4623    field: &'static str,
4624    value: u64,
4625    minimum: u64,
4626    maximum: u64,
4627) -> Result<(), TransportConfigError> {
4628    if !(minimum..=maximum).contains(&value) {
4629        return Err(TransportConfigError::OutOfRange {
4630            field,
4631            minimum,
4632            maximum,
4633        });
4634    }
4635    Ok(())
4636}
4637
4638fn validate_duration(
4639    field: &'static str,
4640    value: Duration,
4641    minimum_seconds: u64,
4642    maximum_seconds: u64,
4643) -> Result<(), TransportConfigError> {
4644    if value.subsec_nanos() != 0 {
4645        return Err(TransportConfigError::InvalidFormat { field });
4646    }
4647    validate_range(field, value.as_secs(), minimum_seconds, maximum_seconds)
4648}
4649
4650fn usize_to_u64(value: usize) -> u64 {
4651    u64::try_from(value).unwrap_or(u64::MAX)
4652}
4653
4654#[cfg(test)]
4655mod tests {
4656    #[cfg(feature = "secure-job-log-http")]
4657    use std::io::Cursor;
4658    #[cfg(windows)]
4659    use std::os::windows::io::AsHandle as _;
4660    use std::{
4661        collections::VecDeque,
4662        sync::atomic::{AtomicU64, Ordering},
4663    };
4664
4665    use super::*;
4666
4667    const SHA: &str = "0123456789abcdef0123456789abcdef01234567";
4668    const BRANCH_B64: &str = "cnVzdGZlcnJ5L2dvYWwzL2J1aWxkcy9qb2ItMQ==";
4669    const WORKFLOW_PATH: &str = ".github/workflows/rustferry-goal3-iphone.yml";
4670    const WORKFLOW_PATH_B64: &str = "LmdpdGh1Yi93b3JrZmxvd3MvcnVzdGZlcnJ5LWdvYWwzLWlwaG9uZS55bWw=";
4671    const WORKFLOW_DISPATCH_RUN_PATH_B64: &str = "LmdpdGh1Yi93b3JrZmxvd3MvcnVzdGZlcnJ5LWdvYWwzLWlwaG9uZS55bWxAcnVzdGZlcnJ5L2dvYWwzL2J1aWxkcy9qb2ItMQ==";
4672    const ENVIRONMENT_B64: &str = "cnVzdGZlcnJ5LWdvYWwzLXNpZ25pbmc=";
4673    const DEPLOYMENT_POLICY_B64: &str = "cnVzdGZlcnJ5L2dvYWwzL2J1aWxkcy8q";
4674    const CERTIFICATE_P12_B64: &str = "UlVTVEZFUlJZX0dPQUwzX0lPU19DRVJUSUZJQ0FURV9QMTI=";
4675    const CERTIFICATE_PASSWORD_B64: &str =
4676        "UlVTVEZFUlJZX0dPQUwzX0lPU19DRVJUSUZJQ0FURV9QQVNTV09SRA==";
4677    const PROVISIONING_PROFILE_B64: &str =
4678        "UlVTVEZFUlJZX0dPQUwzX0lPU19QUk9WSVNJT05JTkdfUFJPRklMRQ==";
4679
4680    fn base64_fixture(value: &str) -> String {
4681        const ALPHABET: &[u8; 64] =
4682            b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
4683        let mut encoded = String::with_capacity(value.len().div_ceil(3) * 4);
4684        for chunk in value.as_bytes().chunks(3) {
4685            let first = chunk[0];
4686            let second = chunk.get(1).copied().unwrap_or(0);
4687            let third = chunk.get(2).copied().unwrap_or(0);
4688            encoded.push(char::from(ALPHABET[usize::from(first >> 2)]));
4689            encoded.push(char::from(
4690                ALPHABET[usize::from((first & 0x03) << 4 | second >> 4)],
4691            ));
4692            encoded.push(if chunk.len() > 1 {
4693                char::from(ALPHABET[usize::from((second & 0x0f) << 2 | third >> 6)])
4694            } else {
4695                '='
4696            });
4697            encoded.push(if chunk.len() > 2 {
4698                char::from(ALPHABET[usize::from(third & 0x3f)])
4699            } else {
4700                '='
4701            });
4702        }
4703        encoded
4704    }
4705
4706    #[cfg(target_os = "linux")]
4707    fn process_is_zombie(process_id: u32) -> bool {
4708        fs::read_to_string(format!("/proc/{process_id}/status"))
4709            .unwrap_or_default()
4710            .lines()
4711            .any(|line| {
4712                line.strip_prefix("State:")
4713                    .is_some_and(|state| state.trim_start().starts_with('Z'))
4714            })
4715    }
4716
4717    #[cfg(all(unix, not(target_os = "linux")))]
4718    const fn process_is_zombie(_: u32) -> bool {
4719        false
4720    }
4721
4722    #[cfg(unix)]
4723    fn assert_process_exits(process_id: u32) {
4724        let deadline = Instant::now() + Duration::from_secs(2);
4725        loop {
4726            let live = Command::new("/bin/kill")
4727                .args(["-0", &process_id.to_string()])
4728                .env_clear()
4729                .stdin(Stdio::null())
4730                .stdout(Stdio::null())
4731                .stderr(Stdio::null())
4732                .status()
4733                .expect("probe fake gh descendant")
4734                .success();
4735            if !live || process_is_zombie(process_id) {
4736                return;
4737            }
4738            if Instant::now() >= deadline {
4739                let _ = Command::new("/bin/kill")
4740                    .args(["-KILL", &process_id.to_string()])
4741                    .env_clear()
4742                    .stdin(Stdio::null())
4743                    .stdout(Stdio::null())
4744                    .stderr(Stdio::null())
4745                    .status();
4746                panic!("fake gh descendant {process_id} remained live");
4747            }
4748            thread::sleep(Duration::from_millis(20));
4749        }
4750    }
4751
4752    #[derive(Debug, Default)]
4753    struct FakeRunner {
4754        responses: VecDeque<Result<Vec<u8>, GhExecutionError>>,
4755        requests: Vec<GhRequest>,
4756        secret_responses: VecDeque<Result<(), GhExecutionError>>,
4757        secret_writes: Vec<RecordedSecretWrite>,
4758    }
4759
4760    #[derive(Debug, Default)]
4761    struct FakeWorkflowDispatchHttpClient {
4762        responses:
4763            VecDeque<Result<GithubWorkflowDispatchHttpResponse, GithubWorkflowDispatchHttpError>>,
4764        requests: Vec<GithubWorkflowDispatchHttpRequest>,
4765    }
4766
4767    impl FakeWorkflowDispatchHttpClient {
4768        fn with(
4769            responses: impl IntoIterator<
4770                Item = Result<GithubWorkflowDispatchHttpResponse, GithubWorkflowDispatchHttpError>,
4771            >,
4772        ) -> Self {
4773            Self {
4774                responses: responses.into_iter().collect(),
4775                requests: Vec::new(),
4776            }
4777        }
4778    }
4779
4780    impl GithubWorkflowDispatchHttpClient for FakeWorkflowDispatchHttpClient {
4781        fn post(
4782            &mut self,
4783            request: &GithubWorkflowDispatchHttpRequest,
4784        ) -> Result<GithubWorkflowDispatchHttpResponse, GithubWorkflowDispatchHttpError> {
4785            self.requests.push(request.clone());
4786            self.responses.pop_front().expect("unexpected dispatch")
4787        }
4788    }
4789
4790    #[derive(Debug, Eq, PartialEq)]
4791    struct RecordedSecretWrite {
4792        request: EnvironmentSecretWriteRequest,
4793        stdin_bytes: usize,
4794        stdin_sha256: String,
4795    }
4796
4797    impl FakeRunner {
4798        fn with(responses: impl IntoIterator<Item = Result<Vec<u8>, GhExecutionError>>) -> Self {
4799            Self {
4800                responses: responses.into_iter().collect(),
4801                requests: Vec::new(),
4802                ..Self::default()
4803            }
4804        }
4805
4806        fn with_secret(responses: impl IntoIterator<Item = Result<(), GhExecutionError>>) -> Self {
4807            Self {
4808                secret_responses: responses.into_iter().collect(),
4809                ..Self::default()
4810            }
4811        }
4812    }
4813
4814    impl GhRunner for FakeRunner {
4815        fn execute(&mut self, request: &GhRequest) -> Result<Vec<u8>, GhExecutionError> {
4816            self.requests.push(request.clone());
4817            self.responses.pop_front().expect("unexpected request")
4818        }
4819    }
4820
4821    impl GhSecretRunner for FakeRunner {
4822        fn set_environment_secret(
4823            &mut self,
4824            request: &EnvironmentSecretWriteRequest,
4825            value: &SecretBytes,
4826            _timeout: Duration,
4827        ) -> Result<(), GhExecutionError> {
4828            self.secret_writes.push(RecordedSecretWrite {
4829                request: request.clone(),
4830                stdin_bytes: value.len(),
4831                stdin_sha256: hex::encode(Sha256::digest(value.expose_secret_bytes())),
4832            });
4833            self.secret_responses
4834                .pop_front()
4835                .expect("unexpected secret write")
4836        }
4837    }
4838
4839    #[derive(Debug, Default)]
4840    struct FakeSleeper(Vec<Duration>);
4841
4842    impl PollSleeper for FakeSleeper {
4843        fn sleep(&mut self, duration: Duration) {
4844            self.0.push(duration);
4845        }
4846    }
4847
4848    fn repository() -> Repository {
4849        Repository::new("ShiroKSH", "rustferry").expect("repository")
4850    }
4851
4852    fn environment() -> ProtectedEnvironment {
4853        ProtectedEnvironment::new("rustferry-goal3-signing").expect("environment")
4854    }
4855
4856    fn workflow_registration() -> WorkflowRegistration {
4857        WorkflowRegistration {
4858            repository: repository(),
4859            id: WorkflowId::new(42).expect("workflow id"),
4860            path: WORKFLOW_PATH.to_owned(),
4861        }
4862    }
4863
4864    fn workflow_dispatch_request() -> WorkflowDispatchRequest {
4865        let namespace = TemporaryBranchNamespace::new("rustferry/goal3/builds").expect("namespace");
4866        let temporary_ref = TemporaryGitRef::new(
4867            &namespace,
4868            BranchName::new("rustferry/goal3/builds/job-1").expect("branch"),
4869        )
4870        .expect("temporary ref");
4871        WorkflowDispatchRequest::new(
4872            repository(),
4873            workflow_registration(),
4874            temporary_ref,
4875            "job-1",
4876            "f".repeat(64),
4877            CommitSha::new(SHA).expect("source revision"),
4878            CommitSha::new("fedcba9876543210fedcba9876543210fedcba98").expect("dispatch revision"),
4879        )
4880        .expect("workflow dispatch request")
4881    }
4882
4883    fn workflow_dispatch_receipt_body(run_id: u64) -> Vec<u8> {
4884        format!(
4885            "{{\"workflow_run_id\":{run_id},\"run_url\":\"https://api.github.com/repos/ShiroKSH/rustferry/actions/runs/{run_id}\",\"html_url\":\"https://github.com/ShiroKSH/rustferry/actions/runs/{run_id}\"}}"
4886        )
4887        .into_bytes()
4888    }
4889
4890    fn environment_secret_write_request() -> EnvironmentSecretWriteRequest {
4891        EnvironmentSecretWriteRequest::new(
4892            repository(),
4893            environment(),
4894            SecretName::new("RUSTFERRY_GOAL3_IOS_CERTIFICATE_PASSWORD").expect("secret name"),
4895        )
4896    }
4897
4898    fn limits(pages: u16, per_page: u8) -> TransportLimits {
4899        TransportLimits::new(
4900            pages,
4901            per_page,
4902            4 * 1024,
4903            4 * 1024 * 1024,
4904            Duration::from_secs(5),
4905            Duration::from_secs(10),
4906        )
4907        .expect("limits")
4908    }
4909
4910    fn run_row(id: u64, status: &str, conclusion: &str) -> Vec<u8> {
4911        format!(
4912            "{id}\t17\t{WORKFLOW_PATH_B64}\t9\t1\t{SHA}\t{BRANCH_B64}\tpush\t{status}\t{conclusion}\n"
4913        )
4914        .into_bytes()
4915    }
4916
4917    fn handle() -> RunHandle {
4918        RunHandle {
4919            id: RunId::new(41).expect("run id"),
4920            workflow_id: WorkflowId::new(17).expect("workflow id"),
4921            workflow_path: WORKFLOW_PATH.to_owned(),
4922            head_sha: CommitSha::new(SHA).expect("sha"),
4923            branch: BranchName::new("rustferry/goal3/builds/job-1").expect("branch"),
4924            event: RunEvent::Push,
4925        }
4926    }
4927
4928    #[test]
4929    fn identifiers_reject_option_path_and_expression_syntax() {
4930        for owner in ["", "-owner", "owner-", "owner/name", "${{x}}", "a--b"] {
4931            assert!(
4932                Repository::new(owner, "repo").is_err(),
4933                "accepted {owner:?}"
4934            );
4935        }
4936        for name in ["", "-repo", "repo/child", "repo..child", "repo.", "${{x}}"] {
4937            assert!(Repository::new("owner", name).is_err(), "accepted {name:?}");
4938        }
4939        for branch in [
4940            "refs/heads/main",
4941            "../main",
4942            "job//one",
4943            "job.lock",
4944            "job~one",
4945            "-job",
4946            "${{x}}",
4947        ] {
4948            assert!(BranchName::new(branch).is_err(), "accepted {branch:?}");
4949        }
4950        assert!(CommitSha::new(SHA).is_ok());
4951        assert!(CommitSha::new(SHA.to_uppercase()).is_err());
4952        assert!(RunId::new(0).is_err());
4953        assert!(ArtifactName::new("rustferry-signed-42-1").is_ok());
4954        assert!(ArtifactName::new("../artifact").is_err());
4955    }
4956
4957    #[test]
4958    fn gh_executable_basename_is_platform_exact() {
4959        #[cfg(windows)]
4960        assert!(executable_basename_matches(
4961            Path::new(r"C:\GitHub\gh.exe"),
4962            "gh"
4963        ));
4964        #[cfg(not(windows))]
4965        assert!(executable_basename_matches(Path::new("/usr/bin/gh"), "gh"));
4966        assert!(!executable_basename_matches(
4967            Path::new("/usr/bin/not-gh"),
4968            "gh"
4969        ));
4970    }
4971
4972    #[test]
4973    fn private_gh_state_is_canonical_and_outside_the_project() {
4974        let neutral = tempfile::TempDir::new().expect("neutral directory");
4975        let neutral_path = fs::canonicalize(neutral.path()).expect("canonical neutral directory");
4976        let state = create_private_gh_state(&neutral_path).expect("private gh state");
4977        assert!(state.root.is_absolute());
4978        assert!(!state.root.starts_with(neutral_path));
4979        let metadata = fs::symlink_metadata(&state.root).expect("private state metadata");
4980        assert!(metadata.is_dir());
4981        assert!(!metadata.file_type().is_symlink());
4982    }
4983
4984    #[test]
4985    fn private_gh_state_rejects_project_local_roots() {
4986        let neutral = tempfile::TempDir::new().expect("neutral directory");
4987        let project_local_state =
4988            tempfile::TempDir::new_in(neutral.path()).expect("project-local state directory");
4989        assert!(matches!(
4990            validate_private_gh_state_root(project_local_state.path(), neutral.path()),
4991            Err(TransportConfigError::InvalidLocalPath {
4992                field: "private gh state directory"
4993            })
4994        ));
4995    }
4996
4997    #[cfg(feature = "secure-job-log-http")]
4998    #[test]
4999    fn job_log_agent_is_direct_https_no_redirect_and_trace_suppressed() {
5000        let client = UreqGithubJobLogHttpClient::new(SecretBytes::new(b"test-token".to_vec()))
5001            .expect("job-log client");
5002        let config = client.agent.config();
5003
5004        assert!(config.https_only());
5005        assert!(config.proxy().is_none());
5006        assert_eq!(config.max_redirects(), 0);
5007        assert!(!config.max_redirects_will_error());
5008        assert_eq!(
5009            config.redirect_auth_headers(),
5010            ureq::config::RedirectAuthHeaders::Never
5011        );
5012        assert!(!config.save_redirect_history());
5013        assert_eq!(
5014            config.max_response_header_size(),
5015            MAX_JOB_LOG_RESPONSE_HEADER_BYTES
5016        );
5017        assert_eq!(
5018            config.tls_config().provider(),
5019            ureq::tls::TlsProvider::Rustls
5020        );
5021        let timeouts = config.timeouts();
5022        assert_eq!(timeouts.global, Some(MAX_JOB_LOG_HTTP_TIMEOUT));
5023        assert_eq!(timeouts.resolve, Some(MAX_JOB_LOG_HTTP_TIMEOUT));
5024        assert_eq!(timeouts.connect, Some(MAX_JOB_LOG_HTTP_TIMEOUT));
5025        assert_eq!(timeouts.send_request, Some(MAX_JOB_LOG_HTTP_TIMEOUT));
5026        assert_eq!(timeouts.recv_response, Some(MAX_JOB_LOG_HTTP_TIMEOUT));
5027        assert_eq!(timeouts.recv_body, Some(MAX_JOB_LOG_HTTP_TIMEOUT));
5028        // ureq reveals paths, queries, and Location at TRACE. The facade cap is
5029        // therefore a signed-redirect confidentiality boundary, not a tuning knob.
5030        assert!(log::STATIC_MAX_LEVEL <= log::LevelFilter::Debug);
5031    }
5032
5033    #[cfg(feature = "secure-job-log-http")]
5034    #[test]
5035    fn job_log_request_attaches_auth_only_to_the_fixed_api_origin() {
5036        const TOKEN: &str = "fake-job-log-token-sentinel";
5037        let client = UreqGithubJobLogHttpClient::new(SecretBytes::new(TOKEN.as_bytes().to_vec()))
5038            .expect("job-log client");
5039        let api = client
5040            .prepare_get(
5041                "/repos/ShiroKSH/rustferry/actions/jobs/42/logs",
5042                true,
5043                GithubJobLogAuthorization::GithubApi,
5044                GithubJobLogAccept::Json,
5045                Duration::from_secs(5),
5046            )
5047            .expect("API request");
5048        let api_headers = api.headers_ref().expect("API headers");
5049        let authorization = api_headers.get("authorization").expect("API authorization");
5050        assert_eq!(
5051            authorization.as_bytes(),
5052            format!("Bearer {TOKEN}").as_bytes()
5053        );
5054        assert!(authorization.is_sensitive());
5055        assert_eq!(
5056            api_headers
5057                .get("x-github-api-version")
5058                .expect("API version"),
5059            JOB_LOG_API_VERSION
5060        );
5061        assert_eq!(JOB_LOG_API_VERSION, "2026-03-10");
5062        assert_eq!(
5063            api.uri_ref().expect("API URI").to_string(),
5064            "https://api.github.com/repos/ShiroKSH/rustferry/actions/jobs/42/logs"
5065        );
5066
5067        let redirect = client
5068            .prepare_get(
5069                "https://pipelines.actions.githubusercontent.com/results/log.txt?signed=secret",
5070                false,
5071                GithubJobLogAuthorization::Omit,
5072                GithubJobLogAccept::PlainText,
5073                Duration::from_secs(5),
5074            )
5075            .expect("signed redirect request");
5076        let redirect_headers = redirect.headers_ref().expect("redirect headers");
5077        assert!(redirect_headers.get("authorization").is_none());
5078        assert!(redirect_headers.get("proxy-authorization").is_none());
5079        assert!(redirect_headers.get("x-github-api-version").is_none());
5080        assert!(redirect_headers.get("cookie").is_none());
5081        assert_eq!(
5082            redirect_headers.get("accept").expect("plain-text accept"),
5083            JOB_LOG_PLAINTEXT_ACCEPT
5084        );
5085    }
5086
5087    #[cfg(feature = "secure-job-log-http")]
5088    #[test]
5089    fn workflow_dispatch_http_request_uses_fixed_origin_headers_and_no_redirects() {
5090        const TOKEN: &str = "fake-dispatch-token-sentinel";
5091        let client = UreqGithubApiHttpClient::new(SecretBytes::new(TOKEN.as_bytes().to_vec()))
5092            .expect("GitHub API client");
5093        let request = workflow_dispatch_request();
5094        let http_request = GithubWorkflowDispatchHttpRequest {
5095            endpoint: request.repository.endpoint(&format!(
5096                "/actions/workflows/{}/dispatches",
5097                request.workflow.id.get()
5098            )),
5099            body: request.body.clone(),
5100            timeout: Duration::from_secs(5),
5101        };
5102        let planned = client
5103            .prepare_workflow_dispatch_post(&http_request)
5104            .expect("dispatch request");
5105        let headers = planned.headers_ref().expect("dispatch headers");
5106        let authorization = headers
5107            .get("authorization")
5108            .expect("dispatch authorization");
5109        assert_eq!(
5110            authorization.as_bytes(),
5111            format!("Bearer {TOKEN}").as_bytes()
5112        );
5113        assert!(authorization.is_sensitive());
5114        assert_eq!(
5115            headers.get("accept").expect("accept"),
5116            WORKFLOW_DISPATCH_ACCEPT
5117        );
5118        assert_eq!(
5119            headers.get("content-type").expect("content type"),
5120            WORKFLOW_DISPATCH_CONTENT_TYPE
5121        );
5122        assert_eq!(
5123            headers.get("x-github-api-version").expect("API version"),
5124            WORKFLOW_DISPATCH_API_VERSION
5125        );
5126        assert_eq!(
5127            planned.uri_ref().expect("dispatch URI").to_string(),
5128            "https://api.github.com/repos/ShiroKSH/rustferry/actions/workflows/42/dispatches"
5129        );
5130        assert_eq!(client.agent.config().max_redirects(), 0);
5131        assert_eq!(
5132            client.agent.config().redirect_auth_headers(),
5133            ureq::config::RedirectAuthHeaders::Never
5134        );
5135        let rendered = format!("{client:?}\n{http_request:?}");
5136        assert!(!rendered.contains(TOKEN));
5137        assert!(!rendered.contains("api.github.com"));
5138        assert!(!rendered.contains("request_sha256"));
5139    }
5140
5141    #[cfg(feature = "secure-job-log-http")]
5142    #[test]
5143    fn job_log_request_modes_and_errors_fail_closed_without_sensitive_values() {
5144        const SENTINEL: &str = "signed-url-token-never-surface";
5145        let client = UreqGithubJobLogHttpClient::new(SecretBytes::new(b"test-token".to_vec()))
5146            .expect("job-log client");
5147        for error in [
5148            client
5149                .prepare_get(
5150                    &format!("http://example.invalid/log?sig={SENTINEL}"),
5151                    false,
5152                    GithubJobLogAuthorization::Omit,
5153                    GithubJobLogAccept::PlainText,
5154                    Duration::from_secs(5),
5155                )
5156                .expect_err("HTTP redirect must fail"),
5157            client
5158                .prepare_get(
5159                    "/repos/ShiroKSH/rustferry/actions/jobs/42/logs",
5160                    true,
5161                    GithubJobLogAuthorization::Omit,
5162                    GithubJobLogAccept::Json,
5163                    Duration::from_secs(5),
5164                )
5165                .expect_err("API auth omission must fail"),
5166            map_ureq_request_error(ureq::Error::BadUri(SENTINEL.to_owned())),
5167            map_ureq_body_error(&io::Error::other(SENTINEL)),
5168        ] {
5169            let rendered = format!("{error:?}\n{error}");
5170            assert!(!rendered.contains(SENTINEL));
5171        }
5172        let rendered_client = format!("{client:?}");
5173        assert!(!rendered_client.contains("test-token"));
5174        assert!(rendered_client.contains("<redacted>"));
5175        let response = GithubJobLogHttpResponse::new(
5176            302,
5177            None,
5178            Some(format!("https://example.invalid/log?sig={SENTINEL}")),
5179            Cursor::new(Vec::<u8>::new()),
5180        );
5181        assert!(!format!("{response:?}").contains(SENTINEL));
5182    }
5183
5184    #[cfg(feature = "secure-job-log-http")]
5185    #[test]
5186    fn job_log_body_reads_bounded_chunks_and_checks_cancellation_first() {
5187        let cancellation = rustferry_remote::CancellationToken::new();
5188        let deadline = Instant::now() + Duration::from_secs(5);
5189        let mut body = Cursor::new(b"abcdefgh".to_vec());
5190
5191        assert_eq!(
5192            read_job_log_body_chunk(&mut body, 3, deadline, &cancellation,).expect("first chunk"),
5193            Some(b"abc".to_vec())
5194        );
5195        assert_eq!(
5196            read_job_log_body_chunk(
5197                &mut body,
5198                MAX_JOB_LOG_HTTP_CHUNK_BYTES + 1,
5199                deadline,
5200                &cancellation,
5201            ),
5202            Err(GithubJobLogHttpError::Protocol)
5203        );
5204        assert!(cancellation.cancel());
5205        assert_eq!(
5206            read_job_log_body_chunk(&mut body, 3, deadline, &cancellation,),
5207            Err(GithubJobLogHttpError::Cancelled)
5208        );
5209    }
5210
5211    #[cfg(feature = "secure-job-log-http")]
5212    #[test]
5213    fn job_log_deadlines_and_post_io_cancellation_fail_closed() {
5214        struct StallingReader;
5215
5216        impl Read for StallingReader {
5217            fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
5218                thread::sleep(Duration::from_millis(30));
5219                buffer[0] = b'x';
5220                Ok(1)
5221            }
5222        }
5223
5224        struct CancellingReader(rustferry_remote::CancellationToken);
5225
5226        impl Read for CancellingReader {
5227            fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
5228                buffer[0] = b'x';
5229                assert!(self.0.cancel());
5230                Ok(1)
5231            }
5232        }
5233
5234        struct CancellingEofReader(rustferry_remote::CancellationToken);
5235
5236        impl Read for CancellingEofReader {
5237            fn read(&mut self, _buffer: &mut [u8]) -> io::Result<usize> {
5238                assert!(self.0.cancel());
5239                Ok(0)
5240            }
5241        }
5242
5243        let active = rustferry_remote::CancellationToken::new();
5244        assert_eq!(
5245            read_job_log_body_chunk(
5246                &mut StallingReader,
5247                1,
5248                Instant::now() + Duration::from_millis(10),
5249                &active,
5250            ),
5251            Err(GithubJobLogHttpError::TimedOut)
5252        );
5253
5254        let cancelled_during_read = rustferry_remote::CancellationToken::new();
5255        assert_eq!(
5256            read_job_log_body_chunk(
5257                &mut CancellingReader(cancelled_during_read.clone()),
5258                1,
5259                Instant::now() + Duration::from_secs(1),
5260                &cancelled_during_read,
5261            ),
5262            Err(GithubJobLogHttpError::Cancelled)
5263        );
5264
5265        let cancelled_at_eof = rustferry_remote::CancellationToken::new();
5266        assert_eq!(
5267            read_job_log_body_chunk(
5268                &mut CancellingEofReader(cancelled_at_eof.clone()),
5269                1,
5270                Instant::now() + Duration::from_secs(1),
5271                &cancelled_at_eof,
5272            ),
5273            Err(GithubJobLogHttpError::Cancelled)
5274        );
5275
5276        let cancelled_after_get = rustferry_remote::CancellationToken::new();
5277        assert!(cancelled_after_get.cancel());
5278        assert_eq!(
5279            check_job_log_http_active(
5280                &cancelled_after_get,
5281                Instant::now() + Duration::from_secs(1),
5282            ),
5283            Err(GithubJobLogHttpError::Cancelled)
5284        );
5285        assert_eq!(
5286            check_job_log_http_active(
5287                &rustferry_remote::CancellationToken::new(),
5288                Instant::now()
5289                    .checked_sub(Duration::from_millis(1))
5290                    .expect("past deadline"),
5291            ),
5292            Err(GithubJobLogHttpError::TimedOut)
5293        );
5294    }
5295
5296    #[cfg(feature = "secure-job-log-http")]
5297    #[test]
5298    #[allow(
5299        clippy::too_many_lines,
5300        reason = "the self-contained fake keeps ureq's unversioned transport API out of production code"
5301    )]
5302    fn job_log_ureq_deadline_and_redirect_policy_are_enforced() {
5303        use ureq::unversioned::resolver::{ResolvedSocketAddrs, Resolver};
5304        use ureq::unversioned::transport::{
5305            Buffers, ConnectionDetails, Connector, LazyBuffers, NextTimeout, Transport,
5306        };
5307
5308        #[derive(Debug)]
5309        struct LoopbackResolver;
5310
5311        impl Resolver for LoopbackResolver {
5312            fn resolve(
5313                &self,
5314                _uri: &ureq::http::Uri,
5315                _config: &ureq::config::Config,
5316                _timeout: NextTimeout,
5317            ) -> Result<ResolvedSocketAddrs, ureq::Error> {
5318                let mut addresses = self.empty();
5319                addresses.push(std::net::SocketAddr::from(([127, 0, 0, 1], 443)));
5320                Ok(addresses)
5321            }
5322        }
5323
5324        const DELAYED_BODY_RESPONSE: &[u8] = b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 1\r\nConnection: close\r\n\r\n";
5325        const REDIRECT_RESPONSE: &[u8] = b"HTTP/1.1 302 Found\r\nLocation: https://redirect.example.invalid/job.txt?signed=redirect-must-not-surface\r\nContent-Length: 0\r\n\r\n";
5326
5327        #[derive(Debug)]
5328        struct ScriptedConnector {
5329            response: &'static [u8],
5330            connects: Arc<AtomicU64>,
5331        }
5332
5333        impl Connector for ScriptedConnector {
5334            type Out = ScriptedTransport;
5335
5336            fn connect(
5337                &self,
5338                details: &ConnectionDetails<'_>,
5339                _chained: Option<()>,
5340            ) -> Result<Option<Self::Out>, ureq::Error> {
5341                self.connects.fetch_add(1, Ordering::SeqCst);
5342                Ok(Some(ScriptedTransport {
5343                    buffers: LazyBuffers::new(
5344                        details.config.input_buffer_size(),
5345                        details.config.output_buffer_size(),
5346                    ),
5347                    response: self.response,
5348                    response_head_sent: false,
5349                }))
5350            }
5351        }
5352
5353        #[derive(Debug)]
5354        struct ScriptedTransport {
5355            buffers: LazyBuffers,
5356            response: &'static [u8],
5357            response_head_sent: bool,
5358        }
5359
5360        impl Transport for ScriptedTransport {
5361            fn buffers(&mut self) -> &mut dyn Buffers {
5362                &mut self.buffers
5363            }
5364
5365            fn transmit_output(
5366                &mut self,
5367                _amount: usize,
5368                _timeout: NextTimeout,
5369            ) -> Result<(), ureq::Error> {
5370                Ok(())
5371            }
5372
5373            fn await_input(&mut self, timeout: NextTimeout) -> Result<bool, ureq::Error> {
5374                if !self.response_head_sent {
5375                    let input = self.buffers.input_append_buf();
5376                    input[..self.response.len()].copy_from_slice(self.response);
5377                    self.buffers.input_appended(self.response.len());
5378                    self.response_head_sent = true;
5379                    return Ok(true);
5380                }
5381
5382                let delay = timeout
5383                    .not_zero()
5384                    .map_or(Duration::from_millis(100), |duration| *duration)
5385                    .min(Duration::from_millis(100));
5386                thread::sleep(delay.saturating_add(Duration::from_millis(5)));
5387                Err(ureq::Error::Timeout(timeout.reason))
5388            }
5389
5390            fn is_open(&mut self) -> bool {
5391                true
5392            }
5393
5394            fn is_tls(&self) -> bool {
5395                true
5396            }
5397        }
5398
5399        const SIGNED_TARGET_SENTINEL: &str =
5400            "https://logs.example.invalid/job.txt?signed=must-not-surface";
5401        const TOKEN_SENTINEL: &[u8] = b"fake-transport-token-must-not-surface";
5402        let mut client = UreqGithubJobLogHttpClient::new(SecretBytes::new(TOKEN_SENTINEL.to_vec()))
5403            .expect("job-log client");
5404        let delayed_connects = Arc::new(AtomicU64::new(0));
5405        client.agent = ureq::Agent::with_parts(
5406            client.agent.config().clone(),
5407            ScriptedConnector {
5408                response: DELAYED_BODY_RESPONSE,
5409                connects: Arc::clone(&delayed_connects),
5410            },
5411            LoopbackResolver,
5412        );
5413        let timeout = Duration::from_millis(40);
5414        let started = Instant::now();
5415        let response = client
5416            .prepare_get(
5417                SIGNED_TARGET_SENTINEL,
5418                false,
5419                GithubJobLogAuthorization::Omit,
5420                GithubJobLogAccept::PlainText,
5421                timeout,
5422            )
5423            .expect("signed request")
5424            .call()
5425            .expect("response head before deadline");
5426        let (_, body) = response.into_parts();
5427        let mut body = UreqGithubJobLogHttpBody {
5428            reader: body.into_reader(),
5429            request_deadline: started.checked_add(timeout).expect("request deadline"),
5430        };
5431        let error = body
5432            .next_chunk(1, &rustferry_remote::CancellationToken::new())
5433            .expect_err("body must time out");
5434
5435        assert_eq!(error, GithubJobLogHttpError::TimedOut);
5436        let rendered = format!("{error:?}\n{error}");
5437        assert!(!rendered.contains(SIGNED_TARGET_SENTINEL));
5438        assert!(!rendered.contains("logs.example.invalid"));
5439        assert!(!rendered.contains("signed=must-not-surface"));
5440        assert!(!rendered.contains(std::str::from_utf8(TOKEN_SENTINEL).expect("ASCII token")));
5441        assert_eq!(delayed_connects.load(Ordering::SeqCst), 1);
5442        assert!(started.elapsed() < Duration::from_secs(1));
5443
5444        let redirect_connects = Arc::new(AtomicU64::new(0));
5445        client.agent = ureq::Agent::with_parts(
5446            client.agent.config().clone(),
5447            ScriptedConnector {
5448                response: REDIRECT_RESPONSE,
5449                connects: Arc::clone(&redirect_connects),
5450            },
5451            LoopbackResolver,
5452        );
5453        let redirect = client
5454            .prepare_get(
5455                SIGNED_TARGET_SENTINEL,
5456                false,
5457                GithubJobLogAuthorization::Omit,
5458                GithubJobLogAccept::PlainText,
5459                Duration::from_secs(1),
5460            )
5461            .expect("signed request")
5462            .call()
5463            .expect("redirect response");
5464
5465        assert_eq!(redirect.status().as_u16(), 302);
5466        assert_eq!(redirect_connects.load(Ordering::SeqCst), 1);
5467        assert_eq!(
5468            redirect.headers().get("location").expect("Location"),
5469            "https://redirect.example.invalid/job.txt?signed=redirect-must-not-surface"
5470        );
5471    }
5472
5473    #[cfg(feature = "secure-job-log-http")]
5474    #[test]
5475    fn job_log_token_validation_is_bounded_and_never_accepts_output_lines() {
5476        assert_eq!(
5477            validate_github_token(vec![b'x'; MAX_GITHUB_TOKEN_BYTES], false)
5478                .expect("maximum token")
5479                .len(),
5480            MAX_GITHUB_TOKEN_BYTES
5481        );
5482        assert_eq!(
5483            validate_github_token(b"test-token\r\n".to_vec(), true)
5484                .expect("gh output token")
5485                .len(),
5486            "test-token".len()
5487        );
5488        assert!(matches!(
5489            validate_github_token(b"test-token\nsecond-line".to_vec(), true),
5490            Err(GhExecutionError::AuthenticationUnavailable)
5491        ));
5492        assert!(matches!(
5493            validate_github_token(vec![b'x'; MAX_GITHUB_TOKEN_BYTES + 1], false),
5494            Err(GhExecutionError::OutputLimitExceeded)
5495        ));
5496    }
5497
5498    #[cfg(feature = "secure-job-log-http")]
5499    #[test]
5500    fn job_log_token_process_uses_only_fixed_auth_argv() {
5501        let executable_directory = tempfile::TempDir::new().expect("executable directory");
5502        let executable =
5503            executable_directory
5504                .path()
5505                .join(if cfg!(windows) { "gh.exe" } else { "gh" });
5506        fs::write(&executable, []).expect("fake gh executable");
5507        let neutral = tempfile::TempDir::new().expect("neutral directory");
5508        let config = tempfile::TempDir::new().expect("config directory");
5509        let runner = GhProcessRunner::new(
5510            &executable,
5511            neutral.path(),
5512            GhAuthentication::config_directory(config.path()).expect("gh config"),
5513        )
5514        .expect("isolated gh runner");
5515        let command = runner.authentication_token_command();
5516
5517        assert_eq!(
5518            command.get_args().collect::<Vec<_>>(),
5519            ["auth", "token", "--hostname", "github.com"]
5520                .map(OsStr::new)
5521                .as_slice()
5522        );
5523    }
5524
5525    #[cfg(unix)]
5526    #[test]
5527    fn private_gh_state_rejects_a_parent_symlink_alias_into_the_project() {
5528        let neutral = tempfile::TempDir::new().expect("neutral directory");
5529        let state = neutral.path().join("state");
5530        fs::create_dir(&state).expect("project-local state directory");
5531        let alias_parent = tempfile::TempDir::new().expect("alias parent");
5532        let alias = alias_parent.path().join("project-alias");
5533        std::os::unix::fs::symlink(neutral.path(), &alias).expect("project symlink alias");
5534
5535        assert!(matches!(
5536            validate_private_gh_state_root(&alias.join("state"), neutral.path()),
5537            Err(TransportConfigError::InvalidLocalPath {
5538                field: "private gh state directory"
5539            })
5540        ));
5541    }
5542
5543    #[test]
5544    fn gh_process_environment_uses_only_private_state_and_disables_telemetry() {
5545        let executable_directory = tempfile::TempDir::new().expect("executable directory");
5546        let executable =
5547            executable_directory
5548                .path()
5549                .join(if cfg!(windows) { "gh.exe" } else { "gh" });
5550        fs::write(&executable, []).expect("fake gh executable");
5551        let neutral = tempfile::TempDir::new().expect("neutral directory");
5552        let runner = GhProcessRunner::new(
5553            &executable,
5554            neutral.path(),
5555            GhAuthentication::environment_token(TokenEnvironmentVariable::GithubToken),
5556        )
5557        .expect("isolated gh runner");
5558        let request = GhRequest {
5559            method: ApiMethod::Get,
5560            endpoint: "/user".to_owned(),
5561            fields: Vec::new(),
5562            jq: Some(USER_QUERY),
5563            silent: false,
5564            output_limit: 1024,
5565            timeout: Duration::from_secs(1),
5566            api_version: API_VERSION,
5567        };
5568        let command = runner.command(&request);
5569        let environment = command
5570            .get_envs()
5571            .map(|(name, value)| (name.to_owned(), value.map(OsStr::to_owned)))
5572            .collect::<std::collections::BTreeMap<_, _>>();
5573        let value = |name: &str| {
5574            environment
5575                .get(OsStr::new(name))
5576                .and_then(Option::as_ref)
5577                .map(OsString::as_os_str)
5578        };
5579
5580        assert_eq!(
5581            value("GH_CONFIG_DIR"),
5582            Some(runner.private_state.root.as_os_str())
5583        );
5584        assert_eq!(value("GH_TELEMETRY"), Some(OsStr::new("false")));
5585        assert_eq!(value("DO_NOT_TRACK"), Some(OsStr::new("true")));
5586        for name in [
5587            "XDG_STATE_HOME",
5588            "HOME",
5589            "USERPROFILE",
5590            "APPDATA",
5591            "LOCALAPPDATA",
5592        ] {
5593            assert_eq!(value(name), Some(runner.private_state.root.as_os_str()));
5594        }
5595        #[cfg(windows)]
5596        {
5597            assert_eq!(value("SystemRoot"), Some(runner.system_root.as_os_str()));
5598            assert!(runner.system_root.is_absolute());
5599            assert_eq!(environment.len(), 16);
5600        }
5601        #[cfg(not(windows))]
5602        {
5603            assert_eq!(value("SystemRoot"), None);
5604            assert_eq!(environment.len(), 15);
5605        }
5606        for name in [
5607            "PATH",
5608            "PATHEXT",
5609            "WINDIR",
5610            "HTTP_PROXY",
5611            "HTTPS_PROXY",
5612            "NO_PROXY",
5613            "http_proxy",
5614            "https_proxy",
5615            "no_proxy",
5616            "GH_TOKEN",
5617            "GITHUB_TOKEN",
5618        ] {
5619            assert_eq!(value(name), None, "unexpected child variable {name}");
5620        }
5621    }
5622
5623    #[cfg(windows)]
5624    #[test]
5625    #[ignore = "requires explicit live gh executable and config-directory paths"]
5626    fn live_windows_config_directory_authenticates_through_keyring() {
5627        let executable = std::env::var_os("RUSTFERRY_LIVE_GH_EXECUTABLE")
5628            .expect("set RUSTFERRY_LIVE_GH_EXECUTABLE to the real gh.exe path");
5629        let configuration_directory = std::env::var_os("RUSTFERRY_LIVE_GH_CONFIG_DIR")
5630            .expect("set RUSTFERRY_LIVE_GH_CONFIG_DIR to the authenticated gh config directory");
5631        let neutral = tempfile::TempDir::new().expect("neutral directory");
5632        let authentication = GhAuthentication::config_directory(configuration_directory)
5633            .expect("canonical live gh config directory");
5634        let runner = GhProcessRunner::new(executable, neutral.path(), authentication)
5635            .expect("isolated live gh runner");
5636        let mut transport = GithubTransport::new(runner, TransportLimits::secure_defaults());
5637
5638        let user = transport
5639            .authenticated_user()
5640            .expect("GET /user through Windows keyring authentication");
5641
5642        assert_ne!(user.id(), 0);
5643        assert!(!user.login().is_empty());
5644    }
5645
5646    #[test]
5647    fn environment_secret_write_uses_fixed_argv_and_separate_stdin() {
5648        const SENTINEL: &str = "fake-manual-signing-password-sentinel";
5649
5650        let request = environment_secret_write_request();
5651        let value = SecretBytes::new(SENTINEL.as_bytes().to_vec());
5652        let mut transport = GithubTransport::new(FakeRunner::with_secret([Ok(())]), limits(1, 2));
5653        let receipt = transport
5654            .set_environment_secret(&request, &value)
5655            .expect("secret write");
5656
5657        assert_eq!(receipt.repository(), request.repository());
5658        assert_eq!(receipt.environment(), request.environment());
5659        assert_eq!(receipt.name(), request.name());
5660        let runner = transport.into_runner();
5661        assert!(runner.requests.is_empty());
5662        assert_eq!(runner.secret_writes.len(), 1);
5663        let write = &runner.secret_writes[0];
5664        assert_eq!(write.request, request);
5665        assert_eq!(write.stdin_bytes, SENTINEL.len());
5666        assert_eq!(
5667            write.stdin_sha256,
5668            hex::encode(Sha256::digest(SENTINEL.as_bytes()))
5669        );
5670        assert_eq!(
5671            write.request.arguments(),
5672            [
5673                "secret",
5674                "set",
5675                "RUSTFERRY_GOAL3_IOS_CERTIFICATE_PASSWORD",
5676                "--repo",
5677                "ShiroKSH/rustferry",
5678                "--env",
5679                "rustferry-goal3-signing",
5680            ]
5681            .map(OsString::from)
5682        );
5683        let rendered_arguments = format!("{:?}", write.request.arguments());
5684        assert!(!rendered_arguments.contains("--body"));
5685        assert!(!rendered_arguments.contains(SENTINEL));
5686        assert!(!format!("{runner:?}").contains(SENTINEL));
5687    }
5688
5689    #[test]
5690    fn environment_secret_write_accepts_limit_and_rejects_oversize_before_runner() {
5691        let request = environment_secret_write_request();
5692        let maximum = SecretBytes::new(vec![b'x'; MAX_ENVIRONMENT_SECRET_BYTES]);
5693        let mut at_limit = GithubTransport::new(FakeRunner::with_secret([Ok(())]), limits(1, 2));
5694        at_limit
5695            .set_environment_secret(&request, &maximum)
5696            .expect("maximum-size secret");
5697        assert_eq!(at_limit.into_runner().secret_writes.len(), 1);
5698
5699        let value = SecretBytes::new(vec![b'x'; MAX_ENVIRONMENT_SECRET_BYTES + 1]);
5700        let mut transport = GithubTransport::new(FakeRunner::default(), limits(1, 2));
5701
5702        let error = transport
5703            .set_environment_secret(&request, &value)
5704            .expect_err("oversized secret must fail");
5705
5706        assert_eq!(error, TransportError::EnvironmentSecretTooLarge);
5707        assert!(transport.into_runner().secret_writes.is_empty());
5708    }
5709
5710    #[test]
5711    fn environment_secret_write_failure_does_not_expose_sentinel() {
5712        const SENTINEL: &str = "fake-never-log-this-secret-sentinel";
5713
5714        let request = environment_secret_write_request();
5715        let value = SecretBytes::new(SENTINEL.as_bytes().to_vec());
5716        let mut transport = GithubTransport::new(
5717            FakeRunner::with_secret([Err(GhExecutionError::CommandFailed {
5718                exit_code: Some(17),
5719            })]),
5720            limits(1, 2),
5721        );
5722
5723        let error = transport
5724            .set_environment_secret(&request, &value)
5725            .expect_err("failed secret write");
5726        let runner = transport.into_runner();
5727        let rendered = format!("{error:?}\n{error}\n{request:?}\n{runner:?}");
5728        assert!(!rendered.contains(SENTINEL));
5729        assert!(rendered.contains("status 17"));
5730    }
5731
5732    #[cfg(unix)]
5733    #[test]
5734    fn gh_process_runner_sends_environment_secret_only_through_stdin() {
5735        use std::os::unix::fs::PermissionsExt as _;
5736
5737        const SENTINEL: &str = "fake-process-stdin-only-secret-sentinel";
5738        let executable_directory = tempfile::TempDir::new().expect("executable directory");
5739        let executable = executable_directory.path().join("gh");
5740        fs::write(
5741            &executable,
5742            b"#!/bin/sh\nset -eu\nprintf '%s\\n' \"$@\" > secret-argv\ncat > secret-stdin\n",
5743        )
5744        .expect("fake gh executable");
5745        fs::set_permissions(&executable, fs::Permissions::from_mode(0o700))
5746            .expect("fake gh permissions");
5747        let neutral = tempfile::TempDir::new().expect("neutral directory");
5748        let config = tempfile::TempDir::new().expect("config directory");
5749        let runner = GhProcessRunner::new(
5750            &executable,
5751            neutral.path(),
5752            GhAuthentication::config_directory(config.path()).expect("gh config"),
5753        )
5754        .expect("isolated gh runner");
5755        let request = environment_secret_write_request();
5756        let value = SecretBytes::new(SENTINEL.as_bytes().to_vec());
5757        let mut transport = GithubTransport::new(runner, limits(1, 2));
5758
5759        transport
5760            .set_environment_secret(&request, &value)
5761            .expect("secret write");
5762
5763        assert_eq!(
5764            fs::read_to_string(neutral.path().join("secret-argv")).expect("recorded argv"),
5765            "secret\nset\nRUSTFERRY_GOAL3_IOS_CERTIFICATE_PASSWORD\n--repo\nShiroKSH/rustferry\n--env\nrustferry-goal3-signing\n"
5766        );
5767        assert_eq!(
5768            fs::read(neutral.path().join("secret-stdin")).expect("recorded stdin"),
5769            SENTINEL.as_bytes()
5770        );
5771    }
5772
5773    #[cfg(unix)]
5774    #[test]
5775    fn gh_process_runner_discards_secret_command_output_on_failure() {
5776        use std::os::unix::fs::PermissionsExt as _;
5777
5778        const SENTINEL: &str = "fake-secret-command-output-sentinel";
5779        let executable_directory = tempfile::TempDir::new().expect("executable directory");
5780        let executable = executable_directory.path().join("gh");
5781        fs::write(
5782            &executable,
5783            format!(
5784                "#!/bin/sh\ncat >/dev/null\nprintf '%s' '{SENTINEL}'\nprintf '%s' '{SENTINEL}' >&2\nexit 23\n"
5785            ),
5786        )
5787        .expect("fake gh executable");
5788        fs::set_permissions(&executable, fs::Permissions::from_mode(0o700))
5789            .expect("fake gh permissions");
5790        let neutral = tempfile::TempDir::new().expect("neutral directory");
5791        let config = tempfile::TempDir::new().expect("config directory");
5792        let runner = GhProcessRunner::new(
5793            &executable,
5794            neutral.path(),
5795            GhAuthentication::config_directory(config.path()).expect("gh config"),
5796        )
5797        .expect("isolated gh runner");
5798        let request = environment_secret_write_request();
5799        let value = SecretBytes::new(SENTINEL.as_bytes().to_vec());
5800        let mut transport = GithubTransport::new(runner, limits(1, 2));
5801
5802        let error = transport
5803            .set_environment_secret(&request, &value)
5804            .expect_err("fake gh failure");
5805
5806        let rendered = format!("{error:?}\n{error}");
5807        assert!(!rendered.contains(SENTINEL));
5808        assert!(rendered.contains("status 23"));
5809    }
5810
5811    #[test]
5812    fn process_worker_drain_stops_at_cleanup_deadline() {
5813        let (_stdout_sender, stdout_reader) =
5814            mpsc::sync_channel::<Result<CapturedOutput, GhExecutionError>>(1);
5815        let (_stderr_sender, stderr_reader) =
5816            mpsc::sync_channel::<Result<CapturedOutput, GhExecutionError>>(1);
5817        let started = Instant::now();
5818        let deadline = started
5819            .checked_add(Duration::from_millis(50))
5820            .expect("short cleanup deadline");
5821
5822        let Err(error) = collect_process_workers(None, &stdout_reader, &stderr_reader, deadline)
5823        else {
5824            panic!("open output pipe must stop at the cleanup deadline");
5825        };
5826
5827        assert_eq!(error, GhExecutionError::ProcessIo);
5828        assert!(
5829            started.elapsed() < Duration::from_secs(1),
5830            "bounded output drain exceeded its grace"
5831        );
5832    }
5833
5834    #[test]
5835    fn process_deadline_child_helper() {
5836        const CHILD_MODE: &str = "RUSTFERRY_PROCESS_DEADLINE_CHILD";
5837        const LATE_OUTPUT: &[u8] = b"late-process-output-must-not-succeed";
5838        if std::env::var_os(CHILD_MODE).as_deref() != Some(OsStr::new("1")) {
5839            return;
5840        }
5841        thread::sleep(Duration::from_millis(100));
5842        std::io::stdout()
5843            .write_all(LATE_OUTPUT)
5844            .expect("write late child output");
5845    }
5846
5847    #[test]
5848    fn process_deadline_rejects_exit_and_output_during_poll_sleep() {
5849        const CHILD_MODE: &str = "RUSTFERRY_PROCESS_DEADLINE_CHILD";
5850        const LATE_OUTPUT: &str = "late-process-output-must-not-succeed";
5851        let mut command = Command::new(std::env::current_exe().expect("current test executable"));
5852        command
5853            .args([
5854                "--exact",
5855                "transport::tests::process_deadline_child_helper",
5856                "--nocapture",
5857            ])
5858            .env(CHILD_MODE, "1")
5859            .stdin(Stdio::null())
5860            .stdout(Stdio::piped())
5861            .stderr(Stdio::piped());
5862        configure_process_tree(&mut command);
5863
5864        let started = Instant::now();
5865        let error = run_process_inner(
5866            command,
5867            Duration::from_millis(25),
5868            64 * 1024,
5869            None,
5870            true,
5871            Duration::from_secs(2),
5872        )
5873        .expect_err("an exit observed after the original deadline must time out");
5874
5875        assert_eq!(error, GhExecutionError::TimedOut);
5876        assert!(!format!("{error:?}\n{error}").contains(LATE_OUTPUT));
5877        assert!(
5878            started.elapsed() < Duration::from_secs(2),
5879            "cleanup grace must not become an output-success deadline"
5880        );
5881    }
5882
5883    #[cfg(unix)]
5884    #[test]
5885    fn gh_process_runner_timeout_kills_descendants_holding_output_pipes() {
5886        use std::os::unix::fs::PermissionsExt as _;
5887
5888        const SENTINEL: &str = "fake-timeout-secret-sentinel";
5889        let executable_directory = tempfile::TempDir::new().expect("executable directory");
5890        let executable = executable_directory.path().join("gh");
5891        fs::write(
5892            &executable,
5893            b"#!/bin/sh\nset -eu\n/bin/sleep 30 &\nprintf '%s\\n' \"$!\" > descendant-pid\n/bin/cat >/dev/null\n/bin/sleep 30\n",
5894        )
5895        .expect("fake gh executable");
5896        fs::set_permissions(&executable, fs::Permissions::from_mode(0o700))
5897            .expect("fake gh permissions");
5898        let neutral = tempfile::TempDir::new().expect("neutral directory");
5899        let config = tempfile::TempDir::new().expect("config directory");
5900        let runner = GhProcessRunner::new(
5901            &executable,
5902            neutral.path(),
5903            GhAuthentication::config_directory(config.path()).expect("gh config"),
5904        )
5905        .expect("isolated gh runner");
5906        let limits = TransportLimits::new(
5907            1,
5908            1,
5909            4 * 1024,
5910            4 * 1024 * 1024,
5911            Duration::from_secs(5),
5912            Duration::from_secs(5),
5913        )
5914        .expect("five-second limits");
5915        let request = environment_secret_write_request();
5916        let value = SecretBytes::new(SENTINEL.as_bytes().to_vec());
5917        let mut transport = GithubTransport::new(runner, limits);
5918
5919        let started = Instant::now();
5920        let error = transport
5921            .set_environment_secret(&request, &value)
5922            .expect_err("fake gh must time out");
5923        let elapsed = started.elapsed();
5924
5925        assert_eq!(error, TransportError::Execution(GhExecutionError::TimedOut));
5926        assert!(
5927            elapsed < Duration::from_secs(8),
5928            "process-tree timeout took {elapsed:?}"
5929        );
5930        assert!(!format!("{error:?}\n{error}").contains(SENTINEL));
5931        let descendant_pid = fs::read_to_string(neutral.path().join("descendant-pid"))
5932            .expect("recorded descendant PID")
5933            .trim()
5934            .parse::<u32>()
5935            .expect("numeric descendant PID");
5936        assert_process_exits(descendant_pid);
5937    }
5938
5939    #[test]
5940    fn temporary_ref_can_only_bind_below_exact_namespace() {
5941        let namespace = TemporaryBranchNamespace::new("rustferry/goal3/builds").expect("namespace");
5942        assert!(
5943            TemporaryGitRef::new(
5944                &namespace,
5945                BranchName::new("rustferry/goal3/builds/operation-1").expect("branch")
5946            )
5947            .is_ok()
5948        );
5949        assert!(
5950            TemporaryGitRef::new(
5951                &namespace,
5952                BranchName::new("rustferry/goal3/job-shadow/operation-1").expect("branch")
5953            )
5954            .is_err()
5955        );
5956    }
5957
5958    #[test]
5959    fn workflow_dispatch_uses_exact_v2026_request_and_revalidates_run_id() {
5960        let registration = format!("42\t{WORKFLOW_PATH_B64}\tactive\n");
5961        let expected = workflow_dispatch_request();
5962        let run_name = base64_fixture(expected.run_name());
5963        let run = format!(
5964            "777\t42\t{WORKFLOW_DISPATCH_RUN_PATH_B64}\t8\t1\tfedcba9876543210fedcba9876543210fedcba98\t{BRANCH_B64}\tworkflow_dispatch\tqueued\t\t{run_name}\n"
5965        );
5966        let runner = FakeRunner::with([Ok(registration.into_bytes()), Ok(run.into_bytes())]);
5967        let mut transport = GithubTransport::new(runner, limits(2, 3));
5968        let registration = transport
5969            .workflow_registration(&repository(), WORKFLOW_PATH)
5970            .expect("active exact workflow");
5971        assert_eq!(registration, workflow_registration());
5972
5973        let mut request = expected;
5974        request.workflow = registration;
5975        let mut client =
5976            FakeWorkflowDispatchHttpClient::with([Ok(GithubWorkflowDispatchHttpResponse::new(
5977                200,
5978                Some("application/json; charset=utf-8".to_owned()),
5979                workflow_dispatch_receipt_body(777),
5980            ))]);
5981        let receipt = transport
5982            .dispatch_workflow(&mut client, &request)
5983            .expect("strict 200 receipt");
5984        assert_eq!(receipt.run_id().get(), 777);
5985
5986        let http_request = &client.requests[0];
5987        assert_eq!(
5988            http_request.endpoint(),
5989            "/repos/ShiroKSH/rustferry/actions/workflows/42/dispatches"
5990        );
5991        assert_eq!(http_request.accept(), "application/vnd.github+json");
5992        assert_eq!(http_request.content_type(), "application/json");
5993        assert_eq!(http_request.api_version(), "2026-03-10");
5994        assert_eq!(
5995            http_request.body(),
5996            format!(
5997                "{{\"ref\":\"rustferry/goal3/builds/job-1\",\"inputs\":{{\"operation_id\":\"job-1\",\"request_sha256\":\"{}\",\"source_revision\":\"{SHA}\",\"dispatch_revision\":\"fedcba9876543210fedcba9876543210fedcba98\"}}}}",
5998                "f".repeat(64)
5999            )
6000            .as_bytes()
6001        );
6002        let snapshot = transport
6003            .run_by_id(&receipt)
6004            .expect("run identity from receipt");
6005        assert_eq!(snapshot.handle().id(), receipt.run_id());
6006        assert_eq!(snapshot.handle().event(), RunEvent::WorkflowDispatch);
6007        assert_eq!(snapshot.handle().workflow_path(), WORKFLOW_PATH);
6008
6009        let runner = transport.into_runner();
6010        assert_eq!(runner.requests.len(), 2);
6011        assert_eq!(
6012            runner.requests[0].endpoint(),
6013            "/repos/ShiroKSH/rustferry/actions/workflows/rustferry-goal3-iphone.yml"
6014        );
6015        assert_eq!(
6016            runner.requests[0].api_version_header(),
6017            WORKFLOW_DISPATCH_API_VERSION_HEADER
6018        );
6019        assert_eq!(
6020            runner.requests[1].endpoint(),
6021            "/repos/ShiroKSH/rustferry/actions/runs/777"
6022        );
6023        assert_eq!(
6024            runner.requests[1].api_version_header(),
6025            WORKFLOW_DISPATCH_API_VERSION_HEADER
6026        );
6027    }
6028
6029    #[test]
6030    fn workflow_dispatch_request_rejects_ref_operation_and_digest_drift() {
6031        let namespace = TemporaryBranchNamespace::new("rustferry/goal3/builds").expect("namespace");
6032        let temporary_ref = || {
6033            TemporaryGitRef::new(
6034                &namespace,
6035                BranchName::new("rustferry/goal3/builds/job-1").expect("branch"),
6036            )
6037            .expect("temporary ref")
6038        };
6039        let source = CommitSha::new(SHA).expect("source revision");
6040        let dispatch =
6041            CommitSha::new("fedcba9876543210fedcba9876543210fedcba98").expect("dispatch revision");
6042
6043        for (operation, digest) in [
6044            ("job-2", "f".repeat(64)),
6045            ("../job-1", "f".repeat(64)),
6046            ("job-1", "F".repeat(64)),
6047            ("job-1", "f".repeat(63)),
6048        ] {
6049            assert!(
6050                WorkflowDispatchRequest::new(
6051                    repository(),
6052                    workflow_registration(),
6053                    temporary_ref(),
6054                    operation,
6055                    digest,
6056                    source.clone(),
6057                    dispatch.clone(),
6058                )
6059                .is_err()
6060            );
6061        }
6062        assert!(
6063            WorkflowDispatchRequest::new(
6064                Repository::new("Other", "repository").expect("other repository"),
6065                workflow_registration(),
6066                temporary_ref(),
6067                "job-1",
6068                "f".repeat(64),
6069                source,
6070                dispatch,
6071            )
6072            .is_err()
6073        );
6074    }
6075
6076    #[test]
6077    fn workflow_registration_and_run_by_id_reject_identity_drift() {
6078        assert!(!workflow_run_path_matches(
6079            &format!("{WORKFLOW_PATH}@other-branch"),
6080            WORKFLOW_PATH,
6081            &BranchName::new("rustferry/goal3/builds/job-1").expect("branch"),
6082        ));
6083        assert_eq!(
6084            parse_workflow_registration(
6085                format!("42\t{WORKFLOW_PATH_B64}\tdisabled_manually\n").as_bytes(),
6086                &repository(),
6087                WORKFLOW_PATH,
6088            ),
6089            Err(TransportError::WorkflowInactive)
6090        );
6091        assert_eq!(
6092            parse_workflow_registration(
6093                b"42\tLmdpdGh1Yi93b3JrZmxvd3Mvb3RoZXIueW1s\tactive\n",
6094                &repository(),
6095                WORKFLOW_PATH,
6096            ),
6097            Err(TransportError::WorkflowIdentityMismatch)
6098        );
6099
6100        let request = workflow_dispatch_request();
6101        let run_name = base64_fixture(request.run_name());
6102        let drifted = format!(
6103            "778\t42\t{WORKFLOW_DISPATCH_RUN_PATH_B64}\t8\t1\tfedcba9876543210fedcba9876543210fedcba98\t{BRANCH_B64}\tworkflow_dispatch\tqueued\t\t{run_name}\n"
6104        );
6105        let runner = FakeRunner::with([Ok(drifted.into_bytes())]);
6106        let mut transport = GithubTransport::new(runner, limits(2, 3));
6107        let receipt = WorkflowDispatchReceipt {
6108            repository: request.repository.clone(),
6109            run_id: RunId::new(777).expect("run id"),
6110            workflow_id: request.workflow.id,
6111            workflow_path: request.workflow.path.clone(),
6112            dispatch_revision: request.dispatch_revision.clone(),
6113            branch: request.temporary_ref.branch().clone(),
6114            run_name: request.run_name.clone(),
6115        };
6116        assert_eq!(
6117            transport.run_by_id(&receipt),
6118            Err(TransportError::RunIdentityMismatch)
6119        );
6120    }
6121
6122    #[test]
6123    fn workflow_dispatch_receipt_requires_exact_200_json_and_positive_id() {
6124        let request = workflow_dispatch_request();
6125        let transport = GithubTransport::new(FakeRunner::default(), limits(2, 3));
6126        let cases = [
6127            GithubWorkflowDispatchHttpResponse::new(
6128                302,
6129                Some("application/json".to_owned()),
6130                workflow_dispatch_receipt_body(777),
6131            ),
6132            GithubWorkflowDispatchHttpResponse::new(
6133                200,
6134                None,
6135                workflow_dispatch_receipt_body(777),
6136            ),
6137            GithubWorkflowDispatchHttpResponse::new(
6138                200,
6139                Some("text/plain".to_owned()),
6140                workflow_dispatch_receipt_body(777),
6141            ),
6142            GithubWorkflowDispatchHttpResponse::new(
6143                200,
6144                Some("application/json".to_owned()),
6145                workflow_dispatch_receipt_body(0),
6146            ),
6147            GithubWorkflowDispatchHttpResponse::new(
6148                200,
6149                Some("application/json".to_owned()),
6150                br#"{"workflow_run_id":777,"run_url":"https://api.github.com/repos/x/y/actions/runs/777","html_url":"https://github.com/x/y/actions/runs/777","extra":true}"#.to_vec(),
6151            ),
6152            GithubWorkflowDispatchHttpResponse::new(
6153                200,
6154                Some("application/json".to_owned()),
6155                br#"{"workflow_run_id":777,"workflow_run_id":778,"run_url":"https://api.github.com/repos/ShiroKSH/rustferry/actions/runs/777","html_url":"https://github.com/ShiroKSH/rustferry/actions/runs/777"}"#.to_vec(),
6156            ),
6157            GithubWorkflowDispatchHttpResponse::new(
6158                200,
6159                Some("application/json".to_owned()),
6160                [workflow_dispatch_receipt_body(777), b" null".to_vec()].concat(),
6161            ),
6162            GithubWorkflowDispatchHttpResponse::new(
6163                200,
6164                Some("application/json".to_owned()),
6165                vec![b'x'; MAX_WORKFLOW_DISPATCH_RESPONSE_BYTES + 1],
6166            ),
6167        ];
6168        for response in cases {
6169            let mut client = FakeWorkflowDispatchHttpClient::with([Ok(response)]);
6170            assert!(transport.dispatch_workflow(&mut client, &request).is_err());
6171        }
6172
6173        let mut client =
6174            FakeWorkflowDispatchHttpClient::with([Err(GithubWorkflowDispatchHttpError::TimedOut)]);
6175        assert_eq!(
6176            transport.dispatch_workflow(&mut client, &request),
6177            Err(TransportError::WorkflowDispatchHttp(
6178                GithubWorkflowDispatchHttpError::TimedOut
6179            ))
6180        );
6181    }
6182
6183    #[test]
6184    fn workflow_dispatch_debug_and_errors_omit_tokens_bodies_and_urls() {
6185        const TOKEN: &str = "dispatch-token-must-not-surface";
6186        const URL: &str = "https://api.github.com/repos/private/hidden/actions/runs/777";
6187        let transport = GithubTransport::new(FakeRunner::default(), limits(2, 3));
6188        let request = workflow_dispatch_request();
6189        let mut client = FakeWorkflowDispatchHttpClient::with([Ok(
6190            GithubWorkflowDispatchHttpResponse::new(
6191                200,
6192                Some("application/json".to_owned()),
6193                format!(
6194                    "{{\"workflow_run_id\":777,\"run_url\":\"{URL}\",\"html_url\":\"https://github.com/private/hidden/actions/runs/777\"}}"
6195                )
6196                .into_bytes(),
6197            ),
6198        )]);
6199        let error = transport
6200            .dispatch_workflow(&mut client, &request)
6201            .expect_err("unbound receipt URLs must fail");
6202        let rendered = format!(
6203            "{:?}\n{:?}\n{error:?}\n{error}",
6204            client.requests[0], client.responses
6205        );
6206        assert!(!rendered.contains(TOKEN));
6207        assert!(!rendered.contains(URL));
6208        assert!(!rendered.contains("private/hidden"));
6209        assert!(!rendered.contains("request_sha256"));
6210        assert!(!rendered.contains("/repos/ShiroKSH/rustferry"));
6211    }
6212
6213    #[test]
6214    fn request_arguments_are_explicit_and_do_not_paginate_implicitly() {
6215        let runner = FakeRunner::with([Ok(Vec::new())]);
6216        let mut transport = GithubTransport::new(runner, limits(2, 3));
6217        transport
6218            .cancel_run(&repository(), RunId::new(42).expect("run"))
6219            .expect("cancel");
6220        let runner = transport.into_runner();
6221        let arguments = runner.requests[0].arguments();
6222        assert_eq!(
6223            arguments,
6224            [
6225                "api",
6226                "--hostname",
6227                "github.com",
6228                "--method",
6229                "POST",
6230                "-H",
6231                API_ACCEPT,
6232                "-H",
6233                API_VERSION,
6234                "/repos/ShiroKSH/rustferry/actions/runs/42/cancel",
6235                "--silent",
6236            ]
6237            .map(OsString::from)
6238        );
6239        assert!(!arguments.iter().any(|argument| argument == "--paginate"));
6240    }
6241
6242    #[test]
6243    fn auth_and_repository_metadata_are_locally_validated() {
6244        let runner = FakeRunner::with([
6245            Ok(b"42\tU2hpcm9LU0g=\n".to_vec()),
6246            Ok(b"991\tshiroksh/RustFerry\tfalse\tfalse\tfalse\tmaster\n".to_vec()),
6247        ]);
6248        let mut transport = GithubTransport::new(runner, limits(2, 3));
6249        let user = transport.authenticated_user().expect("auth");
6250        assert_eq!(user.id(), 42);
6251        assert_eq!(user.login(), "ShiroKSH");
6252        let info = transport.repository(&repository()).expect("repository");
6253        assert_eq!(info.id(), 991);
6254        assert_eq!(info.full_name(), "shiroksh/RustFerry");
6255        assert!(!info.is_private());
6256        assert_eq!(info.default_branch().as_str(), "master");
6257    }
6258
6259    #[test]
6260    fn actions_permissions_require_one_exact_enabled_boolean() {
6261        let runner = FakeRunner::with([Ok(b"true\n".to_vec()), Ok(b"false\n".to_vec())]);
6262        let mut transport = GithubTransport::new(runner, limits(2, 3));
6263        assert!(
6264            transport
6265                .actions_permissions(&repository())
6266                .expect("enabled Actions policy")
6267                .enabled()
6268        );
6269        assert!(
6270            !transport
6271                .actions_permissions(&repository())
6272                .expect("disabled Actions policy")
6273                .enabled()
6274        );
6275        let runner = transport.into_runner();
6276        for request in &runner.requests {
6277            assert_eq!(request.method(), ApiMethod::Get);
6278            assert_eq!(
6279                request.endpoint(),
6280                "/repos/ShiroKSH/rustferry/actions/permissions"
6281            );
6282            assert_eq!(request.jq, Some(ACTIONS_PERMISSIONS_QUERY));
6283            assert!(request.fields.is_empty());
6284        }
6285
6286        for malformed in [
6287            b"".as_slice(),
6288            b"TRUE\n".as_slice(),
6289            b"1\n".as_slice(),
6290            b"true\nfalse\n".as_slice(),
6291        ] {
6292            let mut transport =
6293                GithubTransport::new(FakeRunner::with([Ok(malformed.to_vec())]), limits(2, 3));
6294            assert_eq!(
6295                transport.actions_permissions(&repository()),
6296                Err(TransportError::MalformedResponse {
6297                    operation: "GitHub Actions permissions"
6298                })
6299            );
6300        }
6301    }
6302
6303    #[test]
6304    fn repository_credential_is_proved_without_claiming_a_user_identity() {
6305        let runner = FakeRunner::with([
6306            Err(GhExecutionError::CommandFailed { exit_code: Some(1) }),
6307            Ok(b"991\tU2hpcm9LU0gvcnVzdGZlcnJ5\n".to_vec()),
6308        ]);
6309        let mut transport = GithubTransport::new(runner, limits(2, 3));
6310        let principal = transport
6311            .authenticate(&repository())
6312            .expect("repository credential");
6313        assert_eq!(
6314            principal,
6315            AuthenticatedPrincipal::RepositoryCredential { repository_id: 991 }
6316        );
6317        assert_eq!(principal.repository_id(), Some(991));
6318        assert_eq!(principal.label(), "repository-scoped token");
6319        assert_eq!(principal.user_login(), None);
6320
6321        let runner = transport.into_runner();
6322        assert_eq!(runner.requests.len(), 2);
6323        assert_eq!(runner.requests[0].endpoint(), "/user");
6324        assert_eq!(runner.requests[1].endpoint(), "/installation/repositories");
6325    }
6326
6327    #[test]
6328    fn successful_user_probe_takes_precedence_over_installation_fallback() {
6329        let runner = FakeRunner::with([Ok(b"42\tU2hpcm9LU0g=\n".to_vec())]);
6330        let mut transport = GithubTransport::new(runner, limits(2, 3));
6331        let principal = transport
6332            .authenticate(&repository())
6333            .expect("user credential");
6334        assert_eq!(principal.label(), "ShiroKSH");
6335        assert_eq!(principal.user_login(), Some("ShiroKSH"));
6336
6337        let runner = transport.into_runner();
6338        assert_eq!(runner.requests.len(), 1);
6339        assert_eq!(runner.requests[0].endpoint(), "/user");
6340    }
6341
6342    #[test]
6343    fn installation_credential_must_include_the_exact_repository() {
6344        let runner = FakeRunner::with([
6345            Err(GhExecutionError::CommandFailed { exit_code: Some(1) }),
6346            Ok(b"992\tU2hpcm9LU0gvb3RoZXI=\n".to_vec()),
6347        ]);
6348        let mut transport = GithubTransport::new(runner, limits(2, 3));
6349        assert_eq!(
6350            transport.authenticate(&repository()),
6351            Err(TransportError::RepositoryAuthorizationMissing)
6352        );
6353    }
6354
6355    #[test]
6356    fn signing_environment_metadata_uses_exact_bounded_rest_routes() {
6357        let runner = FakeRunner::with([
6358            Ok(format!("{ENVIRONMENT_B64}\tfalse\ttrue\ttrue\n").into_bytes()),
6359            Ok(format!("361471\t{DEPLOYMENT_POLICY_B64}\n").into_bytes()),
6360            Ok(format!(
6361                "{CERTIFICATE_P12_B64}\n{CERTIFICATE_PASSWORD_B64}\n{PROVISIONING_PROFILE_B64}\n"
6362            )
6363            .into_bytes()),
6364        ]);
6365        let mut transport = GithubTransport::new(runner, limits(2, 4));
6366        let environment = environment();
6367
6368        let info = transport
6369            .environment(&repository(), &environment)
6370            .expect("environment metadata");
6371        assert_eq!(info.name(), environment.as_str());
6372        assert!(!info.protected_branches());
6373        assert!(info.custom_branch_policies());
6374        assert!(info.has_required_reviewers());
6375
6376        let policies = transport
6377            .list_deployment_branch_policies(&repository(), &environment)
6378            .expect("branch policies");
6379        assert_eq!(policies.len(), 1);
6380        assert_eq!(policies[0].id(), 361_471);
6381        assert_eq!(policies[0].name(), "rustferry/goal3/builds/*");
6382
6383        let secrets = transport
6384            .list_environment_secrets(&repository(), &environment)
6385            .expect("environment secret metadata");
6386        assert_eq!(
6387            secrets
6388                .iter()
6389                .map(|secret| secret.name().as_str())
6390                .collect::<BTreeSet<_>>(),
6391            BTreeSet::from([
6392                "RUSTFERRY_GOAL3_IOS_CERTIFICATE_P12",
6393                "RUSTFERRY_GOAL3_IOS_CERTIFICATE_PASSWORD",
6394                "RUSTFERRY_GOAL3_IOS_PROVISIONING_PROFILE",
6395            ])
6396        );
6397
6398        let runner = transport.into_runner();
6399        assert_eq!(
6400            runner
6401                .requests
6402                .iter()
6403                .map(GhRequest::endpoint)
6404                .collect::<Vec<_>>(),
6405            [
6406                "/repos/ShiroKSH/rustferry/environments/rustferry-goal3-signing",
6407                "/repos/ShiroKSH/rustferry/environments/rustferry-goal3-signing/deployment-branch-policies",
6408                "/repos/ShiroKSH/rustferry/environments/rustferry-goal3-signing/secrets",
6409            ]
6410        );
6411        assert!(
6412            runner
6413                .requests
6414                .iter()
6415                .all(|request| request.method() == ApiMethod::Get)
6416        );
6417        assert!(
6418            runner
6419                .requests
6420                .iter()
6421                .all(|request| !request.arguments().iter().any(|arg| arg == "--paginate"))
6422        );
6423        assert!(runner.requests[0].fields.is_empty());
6424        assert_eq!(
6425            runner.requests[1].fields,
6426            [
6427                ("per_page".to_owned(), "4".to_owned()),
6428                ("page".to_owned(), "1".to_owned())
6429            ]
6430        );
6431        assert_eq!(runner.requests[2].fields, runner.requests[1].fields);
6432        assert_eq!(runner.requests[0].jq, Some(ENVIRONMENT_QUERY));
6433        assert_eq!(
6434            runner.requests[1].jq,
6435            Some(DEPLOYMENT_BRANCH_POLICY_LIST_QUERY)
6436        );
6437        assert_eq!(runner.requests[2].jq, Some(ENVIRONMENT_SECRET_LIST_QUERY));
6438    }
6439
6440    #[test]
6441    fn signing_environment_metadata_fails_closed_on_identity_shape_and_pagination() {
6442        let mut wrong_environment = GithubTransport::new(
6443            FakeRunner::with([Ok(b"c3RhZ2luZw==\tfalse\ttrue\tfalse\n".to_vec())]),
6444            limits(1, 2),
6445        );
6446        assert_eq!(
6447            wrong_environment.environment(&repository(), &environment()),
6448            Err(TransportError::EnvironmentIdentityMismatch)
6449        );
6450
6451        let duplicate =
6452            format!("361471\t{DEPLOYMENT_POLICY_B64}\n361472\t{DEPLOYMENT_POLICY_B64}\n");
6453        let mut duplicate_policies =
6454            GithubTransport::new(FakeRunner::with([Ok(duplicate.into_bytes())]), limits(2, 3));
6455        assert_eq!(
6456            duplicate_policies.list_deployment_branch_policies(&repository(), &environment()),
6457            Err(TransportError::MalformedResponse {
6458                operation: "deployment branch policy list"
6459            })
6460        );
6461
6462        let mut malformed_secret =
6463            GithubTransport::new(FakeRunner::with([Ok(b"dW5zYWZl\n".to_vec())]), limits(1, 2));
6464        assert_eq!(
6465            malformed_secret.list_environment_secrets(&repository(), &environment()),
6466            Err(TransportError::MalformedResponse {
6467                operation: "environment secret list"
6468            })
6469        );
6470
6471        let mut incomplete = GithubTransport::new(
6472            FakeRunner::with([Ok(format!("361471\t{DEPLOYMENT_POLICY_B64}\n").into_bytes())]),
6473            limits(1, 1),
6474        );
6475        assert_eq!(
6476            incomplete.list_deployment_branch_policies(&repository(), &environment()),
6477            Err(TransportError::PaginationLimitReached {
6478                resource: "deployment branch policy"
6479            })
6480        );
6481    }
6482
6483    #[test]
6484    fn repository_identity_mismatch_is_rejected() {
6485        let runner = FakeRunner::with([Ok(
6486            b"991\tattacker/rustferry\tfalse\tfalse\tfalse\tmaster\n".to_vec(),
6487        )]);
6488        let mut transport = GithubTransport::new(runner, limits(1, 3));
6489        assert_eq!(
6490            transport.repository(&repository()),
6491            Err(TransportError::RepositoryIdentityMismatch)
6492        );
6493    }
6494
6495    #[test]
6496    fn run_lookup_uses_exact_sha_branch_event_and_workflow() {
6497        let unrelated_sha = format!(
6498            "40\t17\t{WORKFLOW_PATH_B64}\t8\t1\t{}\t{}\tpush\tqueued\t\n",
6499            "f".repeat(40),
6500            BRANCH_B64
6501        );
6502        let unusual_path_b64 = "LmdpdGh1Yi93b3JrZmxvd3Mvb3RoZXIgd29ya2Zsb3cueW1s";
6503        let unrelated_workflow =
6504            format!("39\t16\t{unusual_path_b64}\t7\t1\t{SHA}\t{BRANCH_B64}\tpush\tqueued\t\n");
6505        let matching = String::from_utf8(run_row(41, "queued", "")).expect("row");
6506        let runner = FakeRunner::with([Ok(format!(
6507            "{unrelated_sha}{unrelated_workflow}{matching}"
6508        )
6509        .into_bytes())]);
6510        let mut transport = GithubTransport::new(runner, limits(2, 4));
6511        let branch = BranchName::new("rustferry/goal3/builds/job-1").expect("branch");
6512        let run = transport
6513            .find_run(
6514                &repository(),
6515                WORKFLOW_PATH,
6516                &CommitSha::new(SHA).expect("sha"),
6517                &branch,
6518                RunEvent::Push,
6519            )
6520            .expect("run");
6521        assert_eq!(run.id().get(), 41);
6522        assert_eq!(run.workflow_path(), WORKFLOW_PATH);
6523        let runner = transport.into_runner();
6524        let arguments = runner.requests[0].arguments();
6525        assert!(arguments.contains(&OsString::from("/repos/ShiroKSH/rustferry/actions/runs")));
6526        for expected in [
6527            format!("head_sha={SHA}"),
6528            "branch=rustferry/goal3/builds/job-1".to_owned(),
6529            "event=push".to_owned(),
6530            "exclude_pull_requests=true".to_owned(),
6531        ] {
6532            assert!(arguments.contains(&OsString::from(expected)));
6533        }
6534    }
6535
6536    #[test]
6537    fn run_lookup_rejects_ambiguity_and_incomplete_pagination() {
6538        let two = [
6539            String::from_utf8(run_row(41, "queued", "")).expect("row"),
6540            String::from_utf8(run_row(42, "queued", "")).expect("row"),
6541        ]
6542        .concat();
6543        let runner = FakeRunner::with([Ok(two.into_bytes())]);
6544        let mut transport = GithubTransport::new(runner, limits(2, 3));
6545        let result = transport.find_run(
6546            &repository(),
6547            WORKFLOW_PATH,
6548            &CommitSha::new(SHA).expect("sha"),
6549            &BranchName::new("rustferry/goal3/builds/job-1").expect("branch"),
6550            RunEvent::Push,
6551        );
6552        assert_eq!(result, Err(TransportError::AmbiguousRun));
6553
6554        let full_page = [run_row(40, "queued", ""), run_row(40, "queued", "")].concat();
6555        let runner = FakeRunner::with([Ok(full_page)]);
6556        let mut transport = GithubTransport::new(runner, limits(1, 2));
6557        let result = transport.find_run(
6558            &repository(),
6559            WORKFLOW_PATH,
6560            &CommitSha::new(SHA).expect("sha"),
6561            &BranchName::new("rustferry/goal3/builds/job-1").expect("branch"),
6562            RunEvent::Push,
6563        );
6564        assert_eq!(
6565            result,
6566            Err(TransportError::PaginationLimitReached { resource: "run" })
6567        );
6568    }
6569
6570    #[test]
6571    fn run_lookup_rejects_invalid_or_mismatched_workflow_paths() {
6572        let mut invalid_transport = GithubTransport::new(FakeRunner::default(), limits(1, 2));
6573        assert_eq!(
6574            invalid_transport.find_run(
6575                &repository(),
6576                "../workflows/attacker.yml",
6577                &CommitSha::new(SHA).expect("sha"),
6578                &BranchName::new("rustferry/goal3/builds/job-1").expect("branch"),
6579                RunEvent::Push,
6580            ),
6581            Err(TransportError::InvalidWorkflowPath)
6582        );
6583
6584        let other_path_b64 = "LmdpdGh1Yi93b3JrZmxvd3Mvb3RoZXIueW1s";
6585        let mismatched =
6586            format!("41\t17\t{other_path_b64}\t9\t1\t{SHA}\t{BRANCH_B64}\tpush\tqueued\t\n");
6587        let mut mismatched_transport = GithubTransport::new(
6588            FakeRunner::with([Ok(mismatched.into_bytes())]),
6589            limits(1, 2),
6590        );
6591        assert_eq!(
6592            mismatched_transport.find_run(
6593                &repository(),
6594                WORKFLOW_PATH,
6595                &CommitSha::new(SHA).expect("sha"),
6596                &BranchName::new("rustferry/goal3/builds/job-1").expect("branch"),
6597                RunEvent::Push,
6598            ),
6599            Err(TransportError::RunNotFound)
6600        );
6601    }
6602
6603    #[test]
6604    fn polling_rechecks_identity_and_stops_at_completion() {
6605        let runner = FakeRunner::with([
6606            Ok(run_row(41, "queued", "")),
6607            Ok(run_row(41, "completed", "success")),
6608        ]);
6609        let mut transport = GithubTransport::new(runner, limits(2, 3));
6610        let mut sleeper = FakeSleeper::default();
6611        let result = transport
6612            .wait_for_run_with(
6613                &repository(),
6614                &handle(),
6615                PollPolicy::new(2, Duration::from_millis(250)).expect("poll"),
6616                &mut sleeper,
6617            )
6618            .expect("completed run");
6619        assert_eq!(result.conclusion(), Some(RunConclusion::Success));
6620        assert_eq!(sleeper.0, [Duration::from_millis(250)]);
6621    }
6622
6623    #[test]
6624    fn polling_rejects_changed_immutable_identity() {
6625        let changed = format!(
6626            "41\t17\t{WORKFLOW_PATH_B64}\t9\t1\t{}\t{}\tpush\tqueued\t\n",
6627            "f".repeat(40),
6628            BRANCH_B64
6629        );
6630        let runner = FakeRunner::with([Ok(changed.into_bytes())]);
6631        let mut transport = GithubTransport::new(runner, limits(2, 3));
6632        assert_eq!(
6633            transport.run(&repository(), &handle()),
6634            Err(TransportError::RunIdentityMismatch)
6635        );
6636    }
6637
6638    #[test]
6639    fn artifact_listing_decodes_names_and_validates_digests() {
6640        let digest = format!("sha256:{}", "a".repeat(64));
6641        let row = format!("73\tcnVzdGZlcnJ5LXNpZ25lZC00Mi0x\t22\tfalse\t{digest}\n");
6642        let runner = FakeRunner::with([Ok(row.into_bytes())]);
6643        let mut transport = GithubTransport::new(runner, limits(2, 3));
6644        let artifact = transport
6645            .find_artifact(
6646                &repository(),
6647                RunId::new(42).expect("run"),
6648                &ArtifactName::new("rustferry-signed-42-1").expect("name"),
6649            )
6650            .expect("artifact");
6651        assert_eq!(artifact.id().get(), 73);
6652        assert_eq!(artifact.size_bytes(), 22);
6653        assert_eq!(artifact.digest(), Some(digest.as_str()));
6654    }
6655
6656    #[test]
6657    fn expired_duplicate_and_oversized_artifacts_fail_closed() {
6658        let expired = b"73\tcnVzdGZlcnJ5LXNpZ25lZC00Mi0x\t22\ttrue\t\n".to_vec();
6659        let runner = FakeRunner::with([Ok(expired)]);
6660        let mut transport = GithubTransport::new(runner, limits(2, 3));
6661        assert_eq!(
6662            transport.find_artifact(
6663                &repository(),
6664                RunId::new(42).expect("run"),
6665                &ArtifactName::new("rustferry-signed-42-1").expect("name")
6666            ),
6667            Err(TransportError::ArtifactExpired)
6668        );
6669
6670        let row = b"73\tcnVzdGZlcnJ5LXNpZ25lZC00Mi0x\t99999999\tfalse\t\n".to_vec();
6671        let runner = FakeRunner::with([Ok(row)]);
6672        let mut transport = GithubTransport::new(runner, limits(2, 3));
6673        assert_eq!(
6674            transport.find_artifact(
6675                &repository(),
6676                RunId::new(42).expect("run"),
6677                &ArtifactName::new("rustferry-signed-42-1").expect("name")
6678            ),
6679            Err(TransportError::ArtifactTooLarge)
6680        );
6681    }
6682
6683    #[test]
6684    fn artifact_download_is_no_clobber_and_never_extracts() {
6685        static NEXT: AtomicU64 = AtomicU64::new(1);
6686        let unique = NEXT.fetch_add(1, Ordering::Relaxed);
6687        let directory = std::env::temp_dir().join(format!(
6688            "rustferry-github-transport-{}-{unique}",
6689            std::process::id()
6690        ));
6691        fs::create_dir(&directory).expect("temp directory");
6692        let target = ArtifactDownloadTarget::new(&directory, "signed.ipa.zip").expect("target");
6693        let zip = [b"PK\x05\x06".as_slice(), &[0_u8; 18]].concat();
6694        let artifact = ArtifactInfo {
6695            id: ArtifactId::new(73).expect("artifact"),
6696            name: "rustferry-signed-42-1".to_owned(),
6697            size_bytes: usize_to_u64(zip.len()),
6698            expired: false,
6699            digest: None,
6700        };
6701        let runner = FakeRunner::with([Ok(zip.clone()), Ok(zip)]);
6702        let mut transport = GithubTransport::new(runner, limits(2, 3));
6703        let downloaded = transport
6704            .download_artifact_zip(&repository(), &artifact, &target)
6705            .expect("download");
6706        assert_eq!(downloaded.bytes(), 22);
6707        assert_eq!(
6708            fs::read(downloaded.path()).expect("read"),
6709            [b"PK\x05\x06".as_slice(), &[0_u8; 18]].concat()
6710        );
6711        #[cfg(windows)]
6712        {
6713            let file =
6714                rustferry_core::windows_private_directory::open_private_file(downloaded.path())
6715                    .expect("open downloaded private file");
6716            rustferry_core::windows_private_directory::verify_private_file_handle(file.as_handle())
6717                .expect("verify downloaded private file");
6718        }
6719        assert_eq!(
6720            transport.download_artifact_zip(&repository(), &artifact, &target),
6721            Err(TransportError::DestinationExists)
6722        );
6723        fs::remove_file(downloaded.path()).expect("remove file");
6724        fs::remove_dir(&directory).expect("remove directory");
6725    }
6726
6727    #[cfg(windows)]
6728    #[test]
6729    fn failed_windows_artifact_cleanup_consumes_the_created_handle() {
6730        let directory = tempfile::TempDir::new().expect("temporary directory");
6731        let path = directory.path().join("failed-download.zip");
6732        let file = create_windows_private_file(&path).expect("create private artifact");
6733
6734        remove_failed_windows_artifact(file).expect("remove exact artifact handle");
6735
6736        assert!(!path.exists());
6737    }
6738
6739    #[test]
6740    fn artifact_download_binds_metadata_size_and_digest_before_write() {
6741        static NEXT: AtomicU64 = AtomicU64::new(5_000);
6742        let unique = NEXT.fetch_add(1, Ordering::Relaxed);
6743        let directory = std::env::temp_dir().join(format!(
6744            "rustferry-github-transport-metadata-{}-{unique}",
6745            std::process::id()
6746        ));
6747        fs::create_dir(&directory).expect("temp directory");
6748        let zip = [b"PK\x05\x06".as_slice(), &[0_u8; 18]].concat();
6749
6750        let size_target =
6751            ArtifactDownloadTarget::new(&directory, "size-mismatch.zip").expect("target");
6752        let size_artifact = ArtifactInfo {
6753            id: ArtifactId::new(73).expect("artifact"),
6754            name: "signed".to_owned(),
6755            size_bytes: usize_to_u64(zip.len()).saturating_add(1),
6756            expired: false,
6757            digest: None,
6758        };
6759        let mut size_transport =
6760            GithubTransport::new(FakeRunner::with([Ok(zip.clone())]), limits(2, 3));
6761        assert_eq!(
6762            size_transport.download_artifact_zip(&repository(), &size_artifact, &size_target),
6763            Err(TransportError::ArtifactSizeMismatch)
6764        );
6765        assert!(!size_target.path().exists());
6766
6767        let digest_target =
6768            ArtifactDownloadTarget::new(&directory, "digest-mismatch.zip").expect("target");
6769        let digest_artifact = ArtifactInfo {
6770            id: ArtifactId::new(74).expect("artifact"),
6771            name: "signed".to_owned(),
6772            size_bytes: usize_to_u64(zip.len()),
6773            expired: false,
6774            digest: Some(format!("sha256:{}", "0".repeat(64))),
6775        };
6776        let mut digest_transport = GithubTransport::new(FakeRunner::with([Ok(zip)]), limits(2, 3));
6777        assert_eq!(
6778            digest_transport.download_artifact_zip(&repository(), &digest_artifact, &digest_target),
6779            Err(TransportError::ArtifactDigestMismatch)
6780        );
6781        assert!(!digest_target.path().exists());
6782        fs::remove_dir(&directory).expect("remove directory");
6783    }
6784
6785    #[test]
6786    fn malformed_zip_is_not_written() {
6787        static NEXT: AtomicU64 = AtomicU64::new(10_000);
6788        let unique = NEXT.fetch_add(1, Ordering::Relaxed);
6789        let directory = std::env::temp_dir().join(format!(
6790            "rustferry-github-transport-{}-{unique}",
6791            std::process::id()
6792        ));
6793        fs::create_dir(&directory).expect("temp directory");
6794        let target = ArtifactDownloadTarget::new(&directory, "signed.zip").expect("target");
6795        let artifact = ArtifactInfo {
6796            id: ArtifactId::new(73).expect("artifact"),
6797            name: "signed".to_owned(),
6798            size_bytes: 32,
6799            expired: false,
6800            digest: None,
6801        };
6802        let runner = FakeRunner::with([Ok(b"not a zip response".to_vec())]);
6803        let mut transport = GithubTransport::new(runner, limits(2, 3));
6804        assert_eq!(
6805            transport.download_artifact_zip(&repository(), &artifact, &target),
6806            Err(TransportError::InvalidArtifactZip)
6807        );
6808        assert!(!target.path().exists());
6809        fs::remove_dir(&directory).expect("remove directory");
6810    }
6811
6812    #[test]
6813    fn execution_errors_never_contain_command_output_or_tokens() {
6814        let secret = "ghp_DO_NOT_ECHO_THIS_VALUE";
6815        let error = GhExecutionError::CommandFailed { exit_code: Some(1) };
6816        assert!(!error.to_string().contains(secret));
6817        assert!(!format!("{error:?}").contains(secret));
6818        let transport = TransportError::Execution(error);
6819        assert!(!transport.to_string().contains(secret));
6820    }
6821
6822    #[test]
6823    fn base64_decoder_is_strict_and_canonical() {
6824        assert_eq!(decode_base64("am9iLTE="), Some(b"job-1".to_vec()));
6825        assert_eq!(decode_base64("YQ=="), Some(b"a".to_vec()));
6826        assert_eq!(decode_base64("YR=="), None);
6827        assert_eq!(decode_base64("YQ=A"), None);
6828        assert_eq!(decode_base64("YQ==AAAA"), None);
6829        assert_eq!(decode_base64("not base64"), None);
6830    }
6831}