1use 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
100pub 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
122pub enum TransportConfigError {
123 Empty {
125 field: &'static str,
127 },
128 TooLong {
130 field: &'static str,
132 maximum: usize,
134 },
135 InvalidCharacter {
137 field: &'static str,
139 index: usize,
141 },
142 InvalidFormat {
144 field: &'static str,
146 },
147 OutOfRange {
149 field: &'static str,
151 minimum: u64,
153 maximum: u64,
155 },
156 InvalidLocalPath {
158 field: &'static str,
160 },
161 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#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
201pub struct Repository {
202 owner: String,
203 name: String,
204}
205
206impl Repository {
207 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 pub fn owner(&self) -> &str {
226 &self.owner
227 }
228
229 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#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
245pub struct CommitSha(String);
246
247impl CommitSha {
248 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 pub fn as_str(&self) -> &str {
270 &self.0
271 }
272}
273
274#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
276pub struct BranchName(String);
277
278impl BranchName {
279 #[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 pub fn as_str(&self) -> &str {
310 &self.0
311 }
312}
313
314#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
316pub struct ArtifactName(String);
317
318impl ArtifactName {
319 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 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 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
385pub enum RunEvent {
386 Push,
388 WorkflowDispatch,
390}
391
392impl RunEvent {
393 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#[derive(Clone, Debug, Eq, PartialEq)]
412pub struct TemporaryGitRef(BranchName);
413
414impl TemporaryGitRef {
415 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 pub fn branch(&self) -> &BranchName {
436 &self.0
437 }
438}
439
440#[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 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 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 pub const fn artifact_bytes(self) -> u64 {
512 self.artifact_bytes
513 }
514}
515
516#[derive(Clone, Copy, Debug, Eq, PartialEq)]
518pub struct PollPolicy {
519 attempts: u16,
520 interval: Duration,
521}
522
523impl PollPolicy {
524 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 pub fn secure_defaults() -> Self {
552 Self::new(1_440, Duration::from_secs(5)).expect("constant polling policy is valid")
553 }
554}
555
556#[derive(Clone, Copy, Debug, Eq, PartialEq)]
558pub enum ApiMethod {
559 Get,
561 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#[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 pub const fn method(&self) -> ApiMethod {
590 self.method
591 }
592
593 pub fn endpoint(&self) -> &str {
595 &self.endpoint
596 }
597
598 pub const fn output_limit(&self) -> usize {
600 self.output_limit
601 }
602
603 pub const fn timeout(&self) -> Duration {
605 self.timeout
606 }
607
608 pub const fn api_version_header(&self) -> &'static str {
610 self.api_version
611 }
612
613 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#[derive(Clone, Debug, Eq, PartialEq)]
647pub struct EnvironmentSecretWriteRequest {
648 repository: Repository,
649 environment: ProtectedEnvironment,
650 name: SecretName,
651}
652
653impl EnvironmentSecretWriteRequest {
654 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 pub const fn repository(&self) -> &Repository {
669 &self.repository
670 }
671
672 pub const fn environment(&self) -> &ProtectedEnvironment {
674 &self.environment
675 }
676
677 pub const fn name(&self) -> &SecretName {
679 &self.name
680 }
681
682 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#[derive(Clone, Debug, Eq, PartialEq)]
698pub struct EnvironmentSecretWriteReceipt {
699 repository: Repository,
700 environment: ProtectedEnvironment,
701 name: SecretName,
702}
703
704impl EnvironmentSecretWriteReceipt {
705 pub const fn repository(&self) -> &Repository {
707 &self.repository
708 }
709
710 pub const fn environment(&self) -> &ProtectedEnvironment {
712 &self.environment
713 }
714
715 pub const fn name(&self) -> &SecretName {
717 &self.name
718 }
719}
720
721#[derive(Clone, Copy, Debug, Eq, PartialEq)]
723pub enum GhExecutionError {
724 AuthenticationUnavailable,
726 SecretTooLarge,
728 SpawnFailed,
730 TimedOut,
732 ProcessIo,
734 OutputLimitExceeded,
736 CommandFailed {
738 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
768pub trait GhRunner {
770 fn execute(&mut self, request: &GhRequest) -> Result<Vec<u8>, GhExecutionError>;
776
777 fn workflow_dispatch_client_factory(
787 &self,
788 ) -> Result<Box<dyn GithubWorkflowDispatchHttpClientFactory>, GhExecutionError> {
789 Err(GhExecutionError::AuthenticationUnavailable)
790 }
791}
792
793pub trait GhSecretRunner {
795 fn set_environment_secret(
802 &mut self,
803 request: &EnvironmentSecretWriteRequest,
804 value: &SecretBytes,
805 timeout: Duration,
806 ) -> Result<(), GhExecutionError>;
807}
808
809#[derive(Clone, Copy, Debug, Eq, PartialEq)]
811pub enum TokenEnvironmentVariable {
812 GhToken,
814 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#[derive(Clone, Debug, Eq, PartialEq)]
829pub enum GhAuthentication {
830 EnvironmentToken {
832 variable: TokenEnvironmentVariable,
834 },
835 ConfigDirectory(PathBuf),
837}
838
839impl GhAuthentication {
840 pub const fn environment_token(variable: TokenEnvironmentVariable) -> Self {
843 Self::EnvironmentToken { variable }
844 }
845
846 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#[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 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 #[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 #[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#[cfg(feature = "secure-job-log-http")]
1100pub struct UreqGithubJobLogHttpClient {
1101 agent: ureq::Agent,
1102 authorization: SecretBytes,
1103}
1104
1105#[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 #[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#[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#[derive(Clone, Debug, Eq, PartialEq)]
1613pub struct AuthenticatedUser {
1614 id: u64,
1615 login: String,
1616}
1617
1618#[derive(Clone, Debug, Eq, PartialEq)]
1620pub enum AuthenticatedPrincipal {
1621 User(AuthenticatedUser),
1623 RepositoryCredential {
1626 repository_id: u64,
1628 },
1629}
1630
1631impl AuthenticatedPrincipal {
1632 pub fn label(&self) -> &str {
1634 match self {
1635 Self::User(user) => user.login(),
1636 Self::RepositoryCredential { .. } => "repository-scoped token",
1637 }
1638 }
1639
1640 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 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 pub const fn id(&self) -> u64 {
1660 self.id
1661 }
1662
1663 pub fn login(&self) -> &str {
1665 &self.login
1666 }
1667}
1668
1669#[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 pub const fn id(&self) -> u64 {
1683 self.id
1684 }
1685
1686 pub fn full_name(&self) -> &str {
1688 &self.full_name
1689 }
1690
1691 pub const fn is_private(&self) -> bool {
1693 self.private
1694 }
1695
1696 pub const fn is_archived(&self) -> bool {
1698 self.archived
1699 }
1700
1701 pub const fn is_disabled(&self) -> bool {
1703 self.disabled
1704 }
1705
1706 pub fn default_branch(&self) -> &BranchName {
1708 &self.default_branch
1709 }
1710}
1711
1712#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1714pub struct ActionsPermissions {
1715 enabled: bool,
1716}
1717
1718impl ActionsPermissions {
1719 pub const fn enabled(self) -> bool {
1721 self.enabled
1722 }
1723}
1724
1725#[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 pub const fn repository(&self) -> &Repository {
1752 &self.repository
1753 }
1754
1755 pub const fn id(&self) -> WorkflowId {
1757 self.id
1758 }
1759
1760 pub fn path(&self) -> &str {
1762 &self.path
1763 }
1764}
1765
1766#[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 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 pub const fn repository(&self) -> &Repository {
1876 &self.repository
1877 }
1878
1879 pub const fn workflow(&self) -> &WorkflowRegistration {
1881 &self.workflow
1882 }
1883
1884 pub const fn temporary_ref(&self) -> &TemporaryGitRef {
1886 &self.temporary_ref
1887 }
1888
1889 pub fn operation_id(&self) -> &str {
1891 &self.operation_id
1892 }
1893
1894 pub fn request_sha256(&self) -> &str {
1896 &self.request_sha256
1897 }
1898
1899 pub const fn source_revision(&self) -> &CommitSha {
1901 &self.source_revision
1902 }
1903
1904 pub const fn dispatch_revision(&self) -> &CommitSha {
1906 &self.dispatch_revision
1907 }
1908
1909 pub fn run_name(&self) -> &str {
1911 &self.run_name
1912 }
1913
1914 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#[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 pub fn endpoint(&self) -> &str {
1980 &self.endpoint
1981 }
1982
1983 pub fn body(&self) -> &[u8] {
1985 &self.body
1986 }
1987
1988 pub const fn accept(&self) -> &'static str {
1990 WORKFLOW_DISPATCH_ACCEPT
1991 }
1992
1993 pub const fn content_type(&self) -> &'static str {
1995 WORKFLOW_DISPATCH_CONTENT_TYPE
1996 }
1997
1998 pub const fn api_version(&self) -> &'static str {
2000 WORKFLOW_DISPATCH_API_VERSION
2001 }
2002
2003 pub const fn timeout(&self) -> Duration {
2005 self.timeout
2006 }
2007
2008 pub const fn response_limit(&self) -> usize {
2010 MAX_WORKFLOW_DISPATCH_RESPONSE_BYTES
2011 }
2012}
2013
2014#[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 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2047pub enum GithubWorkflowDispatchHttpError {
2048 Protocol,
2050 TimedOut,
2052 BodyRead,
2054 ResponseTooLarge,
2056 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
2076pub trait GithubWorkflowDispatchHttpClient {
2078 fn post(
2085 &mut self,
2086 request: &GithubWorkflowDispatchHttpRequest,
2087 ) -> Result<GithubWorkflowDispatchHttpResponse, GithubWorkflowDispatchHttpError>;
2088}
2089
2090pub trait GithubWorkflowDispatchHttpClientFactory: Send {
2092 fn create(
2098 self: Box<Self>,
2099 ) -> Result<Box<dyn GithubWorkflowDispatchHttpClient + Send>, GhExecutionError>;
2100}
2101
2102#[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#[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 pub const fn repository(&self) -> &Repository {
2224 &self.repository
2225 }
2226
2227 pub const fn run_id(&self) -> RunId {
2229 self.run_id
2230 }
2231
2232 pub const fn workflow_id(&self) -> WorkflowId {
2234 self.workflow_id
2235 }
2236
2237 pub fn workflow_path(&self) -> &str {
2239 &self.workflow_path
2240 }
2241
2242 pub const fn dispatch_revision(&self) -> &CommitSha {
2244 &self.dispatch_revision
2245 }
2246
2247 pub const fn branch(&self) -> &BranchName {
2249 &self.branch
2250 }
2251
2252 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#[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 pub fn name(&self) -> &str {
2278 &self.name
2279 }
2280
2281 pub const fn protected_branches(&self) -> bool {
2283 self.protected_branches
2284 }
2285
2286 pub const fn custom_branch_policies(&self) -> bool {
2288 self.custom_branch_policies
2289 }
2290
2291 pub const fn has_required_reviewers(&self) -> bool {
2293 self.has_required_reviewers
2294 }
2295}
2296
2297#[derive(Clone, Debug, Eq, PartialEq)]
2299pub struct DeploymentBranchPolicy {
2300 id: u64,
2301 name: String,
2302}
2303
2304impl DeploymentBranchPolicy {
2305 pub const fn id(&self) -> u64 {
2307 self.id
2308 }
2309
2310 pub fn name(&self) -> &str {
2312 &self.name
2313 }
2314}
2315
2316#[derive(Clone, Debug, Eq, PartialEq)]
2318pub struct EnvironmentSecret {
2319 name: SecretName,
2320}
2321
2322impl EnvironmentSecret {
2323 pub fn name(&self) -> &SecretName {
2325 &self.name
2326 }
2327}
2328
2329#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2331pub enum RunStatus {
2332 Requested,
2334 Queued,
2336 Pending,
2338 Waiting,
2340 InProgress,
2342 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 pub const fn is_terminal(self) -> bool {
2361 matches!(self, Self::Completed)
2362 }
2363}
2364
2365#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2367pub enum RunConclusion {
2368 Success,
2370 Failure,
2372 Cancelled,
2374 TimedOut,
2376 Neutral,
2378 Skipped,
2380 Stale,
2382 ActionRequired,
2384 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#[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 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 pub const fn id(&self) -> RunId {
2443 self.id
2444 }
2445
2446 pub const fn workflow_id(&self) -> WorkflowId {
2448 self.workflow_id
2449 }
2450
2451 pub fn workflow_path(&self) -> &str {
2453 &self.workflow_path
2454 }
2455
2456 pub fn head_sha(&self) -> &CommitSha {
2458 &self.head_sha
2459 }
2460
2461 pub fn branch(&self) -> &BranchName {
2463 &self.branch
2464 }
2465
2466 pub const fn event(&self) -> RunEvent {
2468 self.event
2469 }
2470}
2471
2472#[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 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 pub fn handle(&self) -> &RunHandle {
2513 &self.handle
2514 }
2515
2516 pub const fn run_number(&self) -> u64 {
2518 self.run_number
2519 }
2520
2521 pub const fn run_attempt(&self) -> u64 {
2523 self.run_attempt
2524 }
2525
2526 pub const fn status(&self) -> RunStatus {
2528 self.status
2529 }
2530
2531 pub const fn conclusion(&self) -> Option<RunConclusion> {
2533 self.conclusion
2534 }
2535}
2536
2537#[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 pub const fn id(&self) -> ArtifactId {
2550 self.id
2551 }
2552
2553 pub fn name(&self) -> &str {
2555 &self.name
2556 }
2557
2558 pub const fn size_bytes(&self) -> u64 {
2560 self.size_bytes
2561 }
2562
2563 pub const fn is_expired(&self) -> bool {
2565 self.expired
2566 }
2567
2568 pub fn digest(&self) -> Option<&str> {
2570 self.digest.as_deref()
2571 }
2572}
2573
2574#[derive(Clone, Debug, Eq, PartialEq)]
2576pub struct ArtifactDownloadTarget {
2577 directory: PathBuf,
2578 filename: String,
2579}
2580
2581impl ArtifactDownloadTarget {
2582 #[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 pub fn path(&self) -> PathBuf {
2615 self.directory.join(&self.filename)
2616 }
2617}
2618
2619#[derive(Clone, Debug, Eq, PartialEq)]
2621pub struct DownloadedArtifact {
2622 path: PathBuf,
2623 bytes: u64,
2624}
2625
2626impl DownloadedArtifact {
2627 pub fn path(&self) -> &Path {
2629 &self.path
2630 }
2631
2632 pub const fn bytes(&self) -> u64 {
2634 self.bytes
2635 }
2636}
2637
2638#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2640pub enum TransportError {
2641 Execution(GhExecutionError),
2643 WorkflowDispatchHttp(GithubWorkflowDispatchHttpError),
2645 EnvironmentSecretTooLarge,
2647 MalformedResponse {
2649 operation: &'static str,
2651 },
2652 RepositoryIdentityMismatch,
2654 RepositoryAuthorizationMissing,
2656 EnvironmentIdentityMismatch,
2658 InvalidWorkflowPath,
2660 WorkflowIdentityMismatch,
2662 WorkflowInactive,
2664 WorkflowDispatchStatusMismatch,
2666 RunNotFound,
2668 AmbiguousRun,
2670 RunIdentityMismatch,
2672 PollLimitReached,
2674 PaginationLimitReached {
2676 resource: &'static str,
2678 },
2679 ArtifactNotFound,
2681 AmbiguousArtifact,
2683 ArtifactExpired,
2685 ArtifactTooLarge,
2687 InvalidArtifactZip,
2689 ArtifactSizeMismatch,
2691 ArtifactDigestMismatch,
2693 DestinationExists,
2695 ArtifactWriteFailed,
2697 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
2778pub trait PollSleeper {
2780 fn sleep(&mut self, duration: Duration);
2782}
2783
2784#[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#[derive(Clone, Debug, Eq, PartialEq)]
2796pub struct GithubTransport<R> {
2797 runner: R,
2798 limits: TransportLimits,
2799}
2800
2801impl<R> GithubTransport<R> {
2802 pub const fn new(runner: R, limits: TransportLimits) -> Self {
2804 Self { runner, limits }
2805 }
2806
2807 pub fn into_runner(self) -> R {
2809 self.runner
2810 }
2811
2812 pub fn runner(&self) -> &R {
2814 &self.runner
2815 }
2816
2817 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}