#![allow(
dead_code,
reason = "each report uses a subset of the shared mechanics"
)]
use std::env;
use std::error::Error;
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, SystemTime};
use oxide_batch::{CaCertificate, PostgresConfig, TlsMode};
use serde_json::{Value, json};
use sqlx::postgres::PgPoolOptions;
use sqlx::{AssertSqlSafe, Connection, PgConnection, Row};
pub const OBSERVATIONS_ENV: &str = "OXIDEBATCH_SECURITY_OBSERVATIONS";
pub const METADATA_SCHEMA: &str = "oxide_batch";
#[must_use]
pub fn admin_url() -> Option<String> {
variable("OXIDEBATCH_POSTGRES_ADMIN_TEST_URL")
}
#[must_use]
pub fn tls_host() -> Option<String> {
variable("OXIDEBATCH_SECURITY_TLS_HOST")
}
#[must_use]
pub fn tls_mismatch_host() -> Option<String> {
variable("OXIDEBATCH_SECURITY_TLS_MISMATCH_HOST")
}
#[must_use]
pub fn tls_ca() -> Option<PathBuf> {
variable("OXIDEBATCH_SECURITY_TLS_CA").map(PathBuf::from)
}
#[must_use]
pub fn tls_untrusted_ca() -> Option<PathBuf> {
variable("OXIDEBATCH_SECURITY_TLS_UNTRUSTED_CA").map(PathBuf::from)
}
#[must_use]
pub fn plaintext_url() -> Option<String> {
variable("OXIDEBATCH_SECURITY_PLAINTEXT_TEST_URL")
}
#[must_use]
pub fn variable(name: &str) -> Option<String> {
env::var(name).ok().filter(|value| !value.is_empty())
}
pub fn supported_config(
url: String,
ca_certificate: Option<CaCertificate>,
) -> Result<PostgresConfig, Box<dyn Error>> {
Ok(PostgresConfig::new(url)?
.with_tls_mode(TlsMode::VerifyFull { ca_certificate })
.with_connect_timeout(Duration::from_secs(20))?
.with_statement_timeout(Duration::from_mins(2))?
.with_lock_timeout(Duration::from_mins(2))?)
}
pub fn fixture_config(url: String) -> Result<PostgresConfig, Box<dyn Error>> {
Ok(PostgresConfig::new(url)?
.with_tls_mode(TlsMode::Plaintext)
.with_statement_timeout(Duration::from_mins(2))?
.with_lock_timeout(Duration::from_mins(2))?)
}
pub fn read_ca(path: &Path) -> Result<CaCertificate, Box<dyn Error>> {
let pem = fs::read(path)
.map_err(|error| Failure(format!("could not read {}: {error}", path.display())))?;
Ok(CaCertificate::new(pem)?)
}
pub fn with_database(url: &str, name: &str) -> Result<String, Box<dyn Error>> {
let (base, query) = url
.split_once('?')
.map_or((url, None), |(base, query)| (base, Some(query.to_owned())));
let prefix = base
.rsplit_once('/')
.map(|(prefix, _)| prefix)
.ok_or_else(|| Failure(format!("{url} names no database")))?;
Ok(match query {
Some(query) => format!("{prefix}/{name}?{query}"),
None => format!("{prefix}/{name}"),
})
}
pub fn with_host(url: &str, host: &str) -> Result<String, Box<dyn Error>> {
let (scheme, rest) = url
.split_once("://")
.ok_or_else(|| Failure(format!("{url} is not a connection URL")))?;
let (authority, tail) = match rest.find(['/', '?']) {
Some(index) => (&rest[..index], &rest[index..]),
None => (rest, ""),
};
let (credentials, endpoint) = match authority.rsplit_once('@') {
Some((credentials, endpoint)) => (Some(credentials), endpoint),
None => (None, authority),
};
let port = endpoint
.rsplit_once(':')
.map(|(_, port)| port)
.filter(|port| !port.is_empty() && port.bytes().all(|byte| byte.is_ascii_digit()));
let mut replaced = String::from(scheme);
replaced.push_str("://");
if let Some(credentials) = credentials {
replaced.push_str(credentials);
replaced.push('@');
}
replaced.push_str(host);
if let Some(port) = port {
replaced.push(':');
replaced.push_str(port);
}
replaced.push_str(tail);
Ok(replaced)
}
pub fn with_role(url: &str, role: &str, password: &str) -> Result<String, Box<dyn Error>> {
let (scheme, rest) = url
.split_once("://")
.ok_or_else(|| Failure(format!("{url} is not a connection URL")))?;
let (authority, tail) = match rest.find(['/', '?']) {
Some(index) => (&rest[..index], &rest[index..]),
None => (rest, ""),
};
let endpoint = authority
.rsplit_once('@')
.map_or(authority, |(_, endpoint)| endpoint);
Ok(format!("{scheme}://{role}:{password}@{endpoint}{tail}"))
}
pub async fn recreate_database(admin_url: &str, name: &str) -> Result<(), Box<dyn Error>> {
drop_database(admin_url, name).await?;
let pool = PgPoolOptions::new()
.max_connections(1)
.connect(admin_url)
.await?;
sqlx::query(AssertSqlSafe(format!("CREATE DATABASE \"{name}\"")))
.execute(&pool)
.await?;
pool.close().await;
Ok(())
}
pub async fn drop_database(admin_url: &str, name: &str) -> Result<(), Box<dyn Error>> {
let pool = PgPoolOptions::new()
.max_connections(1)
.connect(admin_url)
.await?;
sqlx::query(
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity \
WHERE datname = $1 AND pid <> pg_backend_pid()",
)
.bind(name)
.execute(&pool)
.await?;
sqlx::query(AssertSqlSafe(format!("DROP DATABASE IF EXISTS \"{name}\"")))
.execute(&pool)
.await?;
pool.close().await;
Ok(())
}
pub async fn run_statement(url: &str, statement: String) -> Result<(), Box<dyn Error>> {
let pool = PgPoolOptions::new().max_connections(1).connect(url).await?;
let outcome = sqlx::query(AssertSqlSafe(statement)).execute(&pool).await;
pool.close().await;
outcome?;
Ok(())
}
pub async fn apply_script(url: &str, script: &Path) -> Result<(), Box<dyn Error>> {
let source = fs::read_to_string(script)
.map_err(|error| Failure(format!("could not read {}: {error}", script.display())))?;
let mut connection = PgConnection::connect(url).await?;
let outcome = sqlx::raw_sql(AssertSqlSafe(source))
.execute(&mut connection)
.await;
connection.close().await?;
outcome?;
Ok(())
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum StatementOutcome {
Succeeded,
Refused(String),
}
impl StatementOutcome {
#[must_use]
pub fn code(&self) -> Option<&str> {
match self {
Self::Succeeded => None,
Self::Refused(code) => Some(code.as_str()),
}
}
#[must_use]
pub fn as_str(&self) -> &str {
match self {
Self::Succeeded => "succeeded",
Self::Refused(code) => code.as_str(),
}
}
}
pub const INSUFFICIENT_PRIVILEGE: &str = "42501";
pub async fn attempt_statement(
url: &str,
statement: &str,
) -> Result<StatementOutcome, Box<dyn Error>> {
let mut connection = PgConnection::connect(url).await?;
let outcome = sqlx::query(AssertSqlSafe(statement.to_owned()))
.execute(&mut connection)
.await;
connection.close().await?;
match outcome {
Ok(_) => Ok(StatementOutcome::Succeeded),
Err(sqlx::Error::Database(database)) => Ok(StatementOutcome::Refused(
database
.code()
.map_or_else(|| "unknown".to_owned(), std::borrow::Cow::into_owned),
)),
Err(error) => Err(Box::new(Failure(format!(
"a privilege attempt failed before the server answered: {error}"
)))),
}
}
pub async fn server_version(url: &str) -> Result<String, Box<dyn Error>> {
let pool = PgPoolOptions::new().max_connections(1).connect(url).await?;
let version: String = sqlx::query("SHOW server_version")
.fetch_one(&pool)
.await?
.try_get(0)?;
pool.close().await;
Ok(version)
}
#[must_use]
pub fn major_version(server: &str) -> String {
server.split(['.', ' ']).next().unwrap_or(server).to_owned()
}
#[must_use]
pub fn fixtures() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join("tests")
.join("fixtures")
.join("security")
}
#[must_use]
pub fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")
}
pub fn semantics_paths() -> Result<Vec<String>, Box<dyn Error>> {
let path = fixtures().join("campaign-semantics.json");
let document: Value = serde_json::from_str(&fs::read_to_string(&path)?)?;
let categories = document
.get("categories")
.and_then(Value::as_object)
.ok_or_else(|| Failure("the semantics document declares no categories".to_owned()))?;
let mut paths = categories
.values()
.filter_map(|category| category.get("paths").and_then(Value::as_array))
.flatten()
.filter_map(Value::as_str)
.map(str::to_owned)
.collect::<Vec<_>>();
paths.sort();
paths.dedup();
if paths.is_empty() {
return Err(Box::new(Failure(
"the semantics document declares no paths".to_owned(),
)));
}
Ok(paths)
}
pub fn execution_manifest() -> Result<Value, Box<dyn Error>> {
let root = workspace_root();
let commit = git(&root, &["rev-parse", "HEAD"])
.ok_or_else(|| Failure("the campaign is not running inside a git tree".to_owned()))?;
let mut objects = serde_json::Map::new();
for path in semantics_paths()? {
let object = git(&root, &["rev-parse", &format!("HEAD:{path}")]).ok_or_else(|| {
Failure(format!(
"{path} is declared as campaign semantics and is not present"
))
})?;
objects.insert(path, Value::String(object));
}
Ok(json!({
"execution_commit": commit,
"execution_commit_note": "The tree this run actually executed against, read from the \
checkout the campaign is running in. In CI this is the \
pull-request merge commit rather than the branch head, and it \
is the authority: the objects below are its objects.",
"tree_clean": git(&root, &["status", "--porcelain"]).map(|status| status.is_empty()),
"objects": Value::Object(objects),
}))
}
fn git(root: &Path, arguments: &[&str]) -> Option<String> {
let output = Command::new("git")
.current_dir(root)
.args(arguments)
.output()
.ok()?;
if !output.status.success() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).trim().to_owned())
}
pub fn retain_observation(name: &str, document: &Value) -> Result<Option<PathBuf>, Box<dyn Error>> {
let Some(directory) = variable(OBSERVATIONS_ENV) else {
return Ok(None);
};
let directory = PathBuf::from(directory);
fs::create_dir_all(&directory)?;
let path = directory.join(format!("{name}.json"));
fs::write(
&path,
format!("{}\n", serde_json::to_string_pretty(document)?),
)?;
Ok(Some(path))
}
#[derive(Clone, Copy, Debug)]
pub struct FixedClock(pub SystemTime);
impl oxide_batch::Clock for FixedClock {
fn now(&self) -> SystemTime {
self.0
}
}
#[derive(Debug)]
pub struct Failure(pub String);
impl fmt::Display for Failure {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
impl Error for Failure {}