#![allow(dead_code)]
use std::collections::VecDeque;
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{Shutdown, TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use assert_cmd::Command;
#[must_use]
pub fn fixture_token() -> String {
format!("{}{}", "ghu_", "f1CliFixtureTokenNotARealOne0000")
}
#[must_use]
pub fn fixture_device_code() -> String {
format!("{}{}", "fixture-device-code-", "9f14c0b7a2e34d81")
}
pub const FIXTURE_USER_CODE: &str = "WDJB-MJHT";
pub const FIXTURE_CLIENT_ID: &str = "Iv23liF1TESTCLIENTID";
pub const FIXTURE_APP_SLUG: &str = "runner-manager-test";
#[must_use]
pub fn runner_manager(data_dir: &Path) -> Command {
let mut command = Command::cargo_bin("runner-manager").expect("the binary must be built");
for variable in [
"RUNNER_MANAGER_DATA_DIR",
"RUNNER_MANAGER_GITHUB_BASE_URL",
"RUNNER_MANAGER_GITHUB_CLIENT_ID",
"RUNNER_MANAGER_GITHUB_APP_SLUG",
"RUST_LOG",
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
] {
command.env_remove(variable);
}
command.env("NO_PROXY", "127.0.0.1,localhost,::1");
static NEXT_TAG: AtomicUsize = AtomicUsize::new(0);
command.env(
"RUNNER_MANAGER_SERVICE_NAME_TAG",
format!(
"{}-{}-{}",
data_dir.file_name().unwrap_or_default().to_string_lossy(),
std::process::id(),
NEXT_TAG.fetch_add(1, Ordering::Relaxed)
),
);
command.arg("--data-dir").arg(data_dir);
command
}
#[must_use]
pub fn runner_manager_against(data_dir: &Path, github: &FakeGithub) -> Command {
let mut command = runner_manager(data_dir);
command
.env("RUNNER_MANAGER_GITHUB_BASE_URL", github.base_url())
.env("RUNNER_MANAGER_GITHUB_CLIENT_ID", FIXTURE_CLIENT_ID)
.env("RUNNER_MANAGER_GITHUB_APP_SLUG", FIXTURE_APP_SLUG);
command
}
#[derive(Debug, Clone)]
pub struct Outcome {
pub code: i32,
pub stdout: String,
pub stderr: String,
}
impl Outcome {
#[must_use]
pub fn both(&self) -> String {
format!("{}{}", self.stdout, self.stderr)
}
}
#[must_use]
pub fn run(mut command: Command) -> Outcome {
let output = command.output().expect("the binary must run");
Outcome {
code: output.status.code().unwrap_or(-1),
stdout: String::from_utf8_lossy(&output.stdout).replace("\r\n", "\n"),
stderr: String::from_utf8_lossy(&output.stderr).replace("\r\n", "\n"),
}
}
#[derive(Debug, Clone)]
pub struct Reply {
pub status: u16,
pub body: String,
pub headers: Vec<(String, String)>,
}
impl Reply {
#[must_use]
pub fn json(status: u16, body: impl Into<String>) -> Self {
Self {
status,
body: body.into(),
headers: Vec::new(),
}
}
#[must_use]
pub fn ok(body: impl Into<String>) -> Self {
Self::json(200, body)
}
#[must_use]
pub fn with_header(mut self, name: &str, value: &str) -> Self {
self.headers.push((name.to_string(), value.to_string()));
self
}
}
#[derive(Debug)]
struct Route {
method: String,
path: String,
replies: VecDeque<Reply>,
}
#[derive(Debug, Default)]
struct Shared {
routes: Vec<Route>,
seen: Vec<String>,
}
pub struct FakeGithub {
base_url: String,
shared: Arc<Mutex<Shared>>,
stop: Arc<AtomicBool>,
worker: Option<std::thread::JoinHandle<()>>,
}
impl FakeGithub {
#[must_use]
pub fn start() -> Self {
let listener = TcpListener::bind("127.0.0.1:0").expect("a loopback port must be available");
let port = listener.local_addr().expect("a bound listener").port();
listener
.set_nonblocking(true)
.expect("the listener must be pollable");
let shared = Arc::new(Mutex::new(Shared::default()));
let stop = Arc::new(AtomicBool::new(false));
let worker = {
let shared = Arc::clone(&shared);
let stop = Arc::clone(&stop);
std::thread::spawn(move || {
while !stop.load(Ordering::Relaxed) {
match listener.accept() {
Ok((stream, _)) => {
let shared = Arc::clone(&shared);
std::thread::spawn(move || serve(stream, &shared));
}
Err(ref error) if error.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(2));
}
Err(_) => break,
}
}
})
};
Self {
base_url: format!("http://127.0.0.1:{port}/"),
shared,
stop,
worker: Some(worker),
}
}
#[must_use]
pub fn base_url(&self) -> &str {
&self.base_url
}
pub fn route(&self, method: &str, path: &str, reply: Reply) -> &Self {
let mut shared = self.shared.lock().expect("not poisoned");
if let Some(existing) = shared
.routes
.iter_mut()
.find(|r| r.method == method && r.path == path)
{
existing.replies.push_back(reply);
} else {
shared.routes.push(Route {
method: method.to_string(),
path: path.to_string(),
replies: VecDeque::from([reply]),
});
}
self
}
#[must_use]
pub fn seen(&self) -> Vec<String> {
self.shared.lock().expect("not poisoned").seen.clone()
}
pub fn with_device_code(&self) -> &Self {
self.route(
"POST",
"/login/device/code",
Reply::ok(format!(
r#"{{"device_code":"{}","user_code":"{FIXTURE_USER_CODE}",
"verification_uri":"{}login/device","expires_in":900,"interval":1}}"#,
fixture_device_code(),
self.base_url
)),
)
}
pub fn with_approval(&self) -> &Self {
self.route(
"POST",
"/login/oauth/access_token",
Reply::ok(format!(
r#"{{"access_token":"{}","token_type":"bearer","scope":""}}"#,
fixture_token()
)),
)
}
pub fn with_token_error(&self, code: &str) -> &Self {
self.route(
"POST",
"/login/oauth/access_token",
Reply::ok(format!(r#"{{"error":"{code}"}}"#)),
)
}
pub fn with_no_installations(&self) -> &Self {
self.route(
"GET",
"/user/installations",
Reply::ok(r#"{"total_count":0,"installations":[]}"#),
)
}
pub fn with_installation(
&self,
id: u64,
account: &str,
account_type: &str,
selection: &str,
repositories: &[&str],
) -> &Self {
self.route(
"GET",
"/user/installations",
Reply::ok(format!(
r#"{{"total_count":1,"installations":[
{{"id":{id},"account":{{"login":"{account}","type":"{account_type}"}},
"repository_selection":"{selection}",
"permissions":{{"administration":"write","actions":"read"}}}}]}}"#
)),
);
let entries: Vec<String> = repositories
.iter()
.map(|full_name| format!(r#"{{"full_name":"{full_name}"}}"#))
.collect();
self.route(
"GET",
&format!("/user/installations/{id}/repositories"),
Reply::ok(format!(
r#"{{"total_count":{},"repositories":[{}]}}"#,
entries.len(),
entries.join(",")
)),
)
}
pub fn with_authentication_lockout(&self, retry_after_secs: u64) -> &Self {
self.route(
"GET",
"/user/installations",
Reply {
status: 403,
body: String::new(),
headers: vec![("retry-after".to_string(), retry_after_secs.to_string())],
},
)
}
pub fn with_revoked_credential(&self) -> &Self {
for _ in 0..8 {
self.route(
"GET",
"/user/installations",
Reply::json(401, r#"{"message":"Bad credentials"}"#),
);
}
self
}
}
impl Drop for FakeGithub {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
if let Some(worker) = self.worker.take() {
let _ = worker.join();
}
}
}
fn serve(stream: TcpStream, shared: &Arc<Mutex<Shared>>) {
let _ = stream.set_nonblocking(false);
let _ = stream.set_read_timeout(Some(Duration::from_secs(10)));
let _ = stream.set_write_timeout(Some(Duration::from_secs(10)));
let _ = stream.set_nodelay(true);
let mut reader = BufReader::new(&stream);
let mut request_line = String::new();
if reader.read_line(&mut request_line).is_err() || request_line.trim().is_empty() {
return;
}
let mut parts = request_line.split_whitespace();
let method = parts.next().unwrap_or_default().to_string();
let target = parts.next().unwrap_or_default().to_string();
let path = target
.split('?')
.next()
.unwrap_or_default()
.trim_end_matches('/')
.to_string();
let path = if path.is_empty() {
"/".to_string()
} else {
path
};
let mut content_length = 0_usize;
loop {
let mut header = String::new();
if reader.read_line(&mut header).is_err() {
return;
}
if header.trim().is_empty() {
break;
}
if let Some((name, value)) = header.split_once(':')
&& name.trim().eq_ignore_ascii_case("content-length")
{
content_length = value.trim().parse().unwrap_or(0);
}
}
if content_length > 0 {
let mut body = vec![0_u8; content_length];
let _ = reader.read_exact(&mut body);
}
let reply = {
let mut shared = shared.lock().expect("not poisoned");
shared.seen.push(format!("{method} {path}"));
shared
.routes
.iter_mut()
.find(|route| route.method == method && route.path == path)
.map(|route| {
if route.replies.len() > 1 {
route.replies.pop_front().expect("non-empty")
} else {
route.replies.front().expect("non-empty").clone()
}
})
};
let reply = reply.unwrap_or_else(|| {
Reply::json(
404,
format!(r#"{{"message":"this fixture has no route for {method} {path}"}}"#),
)
});
let extra: String = reply
.headers
.iter()
.map(|(name, value)| format!("{name}: {value}\r\n"))
.collect();
let mut stream = stream;
let response = format!(
"HTTP/1.1 {status} {reason}\r\n\
Content-Type: application/json\r\n\
Content-Length: {length}\r\n\
{extra}\
Connection: close\r\n\
\r\n\
{body}",
status = reply.status,
reason = reason_phrase(reply.status),
length = reply.body.len(),
body = reply.body,
);
if stream.write_all(response.as_bytes()).is_err() {
return;
}
let _ = stream.flush();
let _ = stream.shutdown(Shutdown::Write);
let mut drained = [0_u8; 512];
while matches!(stream.read(&mut drained), Ok(read) if read > 0) {}
}
fn reason_phrase(status: u16) -> &'static str {
match status {
200 => "OK",
401 => "Unauthorized",
403 => "Forbidden",
404 => "Not Found",
_ => "Unknown",
}
}
#[must_use]
pub fn files_under(root: &Path) -> Vec<PathBuf> {
let mut found = Vec::new();
let mut pending = vec![root.to_path_buf()];
while let Some(directory) = pending.pop() {
let Ok(entries) = std::fs::read_dir(&directory) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
pending.push(path);
} else {
found.push(path);
}
}
}
found
}
#[must_use]
pub fn file_contains(path: &Path, needle: &str) -> bool {
let Ok(haystack) = std::fs::read(path) else {
return false;
};
let needle = needle.as_bytes();
haystack.windows(needle.len()).any(|w| w == needle)
}
#[must_use]
pub fn is_the_secret_store(path: &Path) -> bool {
path.components()
.any(|component| component.as_os_str() == "secrets")
}