use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};
use hmac::{Hmac, Mac};
use sha2::{Digest, Sha256};
type HmacSha256 = Hmac<Sha256>;
const TESULTS_URL: &str = "https://www.tesults.com";
const S3_BUCKET: &str = "tesults-results";
const S3_REGION: &str = "us-east-1";
const EXPIRE_BUFFER: u64 = 30;
#[derive(Debug, Default, Clone)]
pub struct Step {
pub name: String,
pub result: String,
pub desc: String,
pub reason: String,
}
#[derive(Debug, Default, Clone)]
pub struct Case {
pub name: String,
pub result: String,
pub suite: String,
pub desc: String,
pub reason: String,
pub raw_result: String,
pub start: i64,
pub end: i64,
pub files: Vec<String>,
pub params: HashMap<String, String>,
pub custom: HashMap<String, String>,
pub steps: Vec<Step>,
}
#[derive(Debug, Default, Clone)]
pub struct Data {
pub target: String,
pub cases: Vec<Case>,
pub integration_name: String,
pub integration_version: String,
pub test_framework: String,
}
#[derive(Debug, Default, Clone)]
pub struct Response {
pub success: bool,
pub message: String,
pub warnings: Vec<String>,
pub errors: Vec<String>,
}
#[derive(Default)]
struct Creds {
permit: bool,
message: String,
key_prefix: String,
access_key: String,
secret_key: String,
session_token: String,
expiration: u64,
}
struct FileUploadResult {
bytes_uploaded: usize,
warning: String,
}
struct UploadFilesResult {
message: String,
warnings: Vec<String>,
}
fn epoch_seconds() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn is_leap(year: u32) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
fn format_utc(secs: u64) -> (String, String) {
let sec = (secs % 60) as u32;
let min = ((secs / 60) % 60) as u32;
let hr = ((secs / 3600) % 24) as u32;
let mut days = (secs / 86400) as u32;
let mut year = 1970u32;
loop {
let diy = if is_leap(year) { 366 } else { 365 };
if days < diy { break; }
days -= diy;
year += 1;
}
let month_days = [31u32, if is_leap(year) { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let mut month = 1u32;
for &dim in &month_days {
if days < dim { break; }
days -= dim;
month += 1;
}
let day = days + 1;
let date = format!("{:04}{:02}{:02}", year, month, day);
let datetime = format!("{:04}{:02}{:02}T{:02}{:02}{:02}Z", year, month, day, hr, min, sec);
(date, datetime)
}
fn sha256_hex(data: &[u8]) -> String {
hex::encode(Sha256::digest(data))
}
fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec<u8> {
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key size");
mac.update(data);
mac.finalize().into_bytes().to_vec()
}
fn url_encode_path(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~' | b'/') {
out.push(b as char);
} else {
out.push_str(&format!("%{:02X}", b));
}
}
out
}
fn s3_filename(path: &str) -> &str {
path.rsplit(['/', '\\']).next().unwrap_or(path)
}
fn step_to_json(s: &Step) -> serde_json::Value {
let mut map = serde_json::Map::new();
map.insert("name".into(), s.name.clone().into());
map.insert("result".into(), (if s.result.is_empty() { "unknown" } else { &s.result }).into());
if !s.desc.is_empty() { map.insert("desc".into(), s.desc.clone().into()); }
if !s.reason.is_empty() { map.insert("reason".into(), s.reason.clone().into()); }
serde_json::Value::Object(map)
}
fn case_to_json(tc: &Case) -> serde_json::Value {
let mut map = serde_json::Map::new();
map.insert("name".into(), tc.name.clone().into());
map.insert("result".into(), (if tc.result.is_empty() { "unknown" } else { &tc.result }).into());
if !tc.suite.is_empty() { map.insert("suite".into(), tc.suite.clone().into()); }
if !tc.desc.is_empty() { map.insert("desc".into(), tc.desc.clone().into()); }
if !tc.reason.is_empty() { map.insert("reason".into(), tc.reason.clone().into()); }
if !tc.raw_result.is_empty() { map.insert("rawResult".into(), tc.raw_result.clone().into()); }
if tc.start > 0 { map.insert("start".into(), tc.start.into()); }
if tc.end > 0 { map.insert("end".into(), tc.end.into()); }
if !tc.files.is_empty() {
let files: Vec<serde_json::Value> = tc.files.iter().map(|f| f.clone().into()).collect();
map.insert("files".into(), files.into());
}
if !tc.params.is_empty() {
let params: serde_json::Map<String, serde_json::Value> = tc.params
.iter()
.map(|(k, v)| (k.clone(), v.clone().into()))
.collect();
map.insert("params".into(), serde_json::Value::Object(params));
}
for (k, v) in &tc.custom {
let key = if k.starts_with('_') { k.clone() } else { format!("_{}", k) };
map.insert(key, v.clone().into());
}
if !tc.steps.is_empty() {
let steps: Vec<serde_json::Value> = tc.steps.iter().map(step_to_json).collect();
map.insert("steps".into(), steps.into());
}
serde_json::Value::Object(map)
}
fn data_to_json(data: &Data) -> serde_json::Value {
let cases: Vec<serde_json::Value> = data.cases.iter().map(case_to_json).collect();
let mut map = serde_json::Map::new();
map.insert("target".into(), data.target.clone().into());
map.insert("results".into(), serde_json::json!({"cases": cases}));
if !data.integration_name.is_empty() || !data.test_framework.is_empty() {
let mut meta = serde_json::Map::new();
if !data.integration_name.is_empty() {
meta.insert("integration_name".into(), data.integration_name.clone().into());
}
if !data.integration_version.is_empty() {
meta.insert("integration_version".into(), data.integration_version.clone().into());
}
if !data.test_framework.is_empty() {
meta.insert("test_framework".into(), data.test_framework.clone().into());
}
map.insert("metadata".into(), serde_json::Value::Object(meta));
}
serde_json::Value::Object(map)
}
fn parse_creds(upload_obj: &serde_json::Value) -> Creds {
let permit = upload_obj["permit"].as_bool().unwrap_or(false);
let message = upload_obj["message"].as_str().unwrap_or("").to_string();
let key_prefix = upload_obj["key"].as_str().unwrap_or("").to_string();
let mut access_key = String::new();
let mut secret_key = String::new();
let mut session_token = String::new();
let mut expiration = 0u64;
if permit {
if let Some(auth) = upload_obj["auth"].as_object() {
access_key = auth.get("AccessKeyId") .and_then(|v| v.as_str()).unwrap_or("").to_string();
secret_key = auth.get("SecretAccessKey") .and_then(|v| v.as_str()).unwrap_or("").to_string();
session_token = auth.get("SessionToken") .and_then(|v| v.as_str()).unwrap_or("").to_string();
expiration = auth.get("Expiration") .and_then(|v| v.as_u64()).unwrap_or(0);
}
}
Creds { permit, message, key_prefix, access_key, secret_key, session_token, expiration }
}
fn refresh_creds(target: &str, key_prefix: &str) -> Creds {
let body = serde_json::json!({"target": target, "key": key_prefix}).to_string();
match ureq::post(&format!("{}/permitupload", TESULTS_URL))
.set("Content-Type", "application/json")
.send_string(&body)
{
Ok(resp) => {
let text = resp.into_string().unwrap_or_default();
match serde_json::from_str::<serde_json::Value>(&text) {
Ok(j) if j["data"]["upload"].is_object() => parse_creds(&j["data"]["upload"]),
_ => Creds { message: "Failed to parse credential refresh response".into(), ..Default::default() },
}
}
Err(_) => Creds { message: "Failed to refresh credentials".into(), ..Default::default() },
}
}
fn upload_file_s3(
local_path: &str,
s3_key: &str,
access_key: &str,
secret_key: &str,
session_token: &str,
) -> FileUploadResult {
let content = match std::fs::read(local_path) {
Ok(c) => c,
Err(_) => return FileUploadResult { bytes_uploaded: 0, warning: format!("File not found: {}", local_path) },
};
let host = format!("{}.s3.{}.amazonaws.com", S3_BUCKET, S3_REGION);
let encoded_path = format!("/{}", url_encode_path(s3_key));
let url = format!("https://{}{}", host, encoded_path);
let (date, datetime) = format_utc(epoch_seconds());
let payload_hash = sha256_hex(&content);
let canon_hdrs = format!(
"content-type:application/octet-stream\nhost:{}\nx-amz-content-sha256:{}\nx-amz-date:{}\nx-amz-security-token:{}\n",
host, payload_hash, datetime, session_token
);
let signed_hdrs = "content-type;host;x-amz-content-sha256;x-amz-date;x-amz-security-token";
let canon_req = format!("PUT\n{}\n\n{}\n{}\n{}", encoded_path, canon_hdrs, signed_hdrs, payload_hash);
let cred_scope = format!("{}/{}/s3/aws4_request", date, S3_REGION);
let str_to_sign = format!("AWS4-HMAC-SHA256\n{}\n{}\n{}", datetime, cred_scope, sha256_hex(canon_req.as_bytes()));
let k_date = hmac_sha256(format!("AWS4{}", secret_key).as_bytes(), date.as_bytes());
let k_region = hmac_sha256(&k_date, S3_REGION.as_bytes());
let k_service = hmac_sha256(&k_region, b"s3");
let k_signing = hmac_sha256(&k_service, b"aws4_request");
let sig = hex::encode(hmac_sha256(&k_signing, str_to_sign.as_bytes()));
let auth = format!(
"AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}",
access_key, cred_scope, signed_hdrs, sig
);
let file_size = content.len();
match ureq::put(&url)
.set("Authorization", &auth)
.set("x-amz-date", &datetime)
.set("x-amz-security-token", session_token)
.set("x-amz-content-sha256", &payload_hash)
.set("Content-Type", "application/octet-stream")
.send_bytes(&content)
{
Ok(_) => FileUploadResult { bytes_uploaded: file_size, warning: String::new() },
Err(ureq::Error::Status(code, _)) => FileUploadResult {
bytes_uploaded: 0,
warning: format!("Failed to upload {} (HTTP {})", local_path, code),
},
Err(e) => FileUploadResult {
bytes_uploaded: 0,
warning: format!("Failed to upload {}: {}", local_path, e),
},
}
}
fn upload_files(data: &Data, mut creds: Creds) -> UploadFilesResult {
let mut result = UploadFilesResult { message: String::new(), warnings: Vec::new() };
if !creds.permit {
if !creds.message.is_empty() { result.warnings.push(creds.message.clone()); }
return result;
}
let mut files_uploaded = 0usize;
let mut bytes_uploaded = 0usize;
for (idx, tc) in data.cases.iter().enumerate() {
for path in &tc.files {
if epoch_seconds() + EXPIRE_BUFFER > creds.expiration {
let key_prefix = creds.key_prefix.clone();
creds = refresh_creds(&data.target, &key_prefix);
if !creds.permit {
result.warnings.push("Unable to refresh upload credentials".into());
return result;
}
}
let s3_key = format!("{}/{}/{}", creds.key_prefix, idx, s3_filename(path));
let r = upload_file_s3(path, &s3_key, &creds.access_key, &creds.secret_key, &creds.session_token);
if !r.warning.is_empty() {
result.warnings.push(r.warning);
} else {
files_uploaded += 1;
bytes_uploaded += r.bytes_uploaded;
}
}
}
result.message = format!("{} files uploaded. {} bytes uploaded.", files_uploaded, bytes_uploaded);
result
}
fn make_error(msg: String) -> Response {
Response { success: false, message: msg.clone(), warnings: vec![], errors: vec![msg] }
}
pub fn upload(data: Data) -> Response {
if data.target.is_empty() {
return make_error("Target token not provided.".into());
}
let body = match serde_json::to_string(&data_to_json(&data)) {
Ok(b) => b,
Err(e) => return make_error(format!("Failed to serialise data to JSON: {}", e)),
};
let http_resp = match ureq::post(&format!("{}/results", TESULTS_URL))
.set("Content-Type", "application/json")
.send_string(&body)
{
Ok(r) => r,
Err(ureq::Error::Status(code, resp)) => {
let text = resp.into_string().unwrap_or_default();
let msg = serde_json::from_str::<serde_json::Value>(&text)
.ok()
.and_then(|j| j["error"]["message"].as_str().map(|s| s.to_string()))
.unwrap_or_else(|| format!("Upload failed (HTTP {}).", code));
return make_error(msg);
}
Err(_) => return make_error("Unable to connect to Tesults.".into()),
};
let text = match http_resp.into_string() {
Ok(t) => t,
Err(_) => return make_error("Failed to read server response.".into()),
};
let j: serde_json::Value = match serde_json::from_str(&text) {
Ok(v) => v,
Err(_) => return make_error("Failed to parse server response.".into()),
};
let mut msg = j["data"]["message"].as_str().unwrap_or("").to_string();
let mut warnings = Vec::new();
if j["data"]["upload"].is_object() {
let creds = parse_creds(&j["data"]["upload"]);
let r = upload_files(&data, creds);
if !r.message.is_empty() {
msg.push(' ');
msg.push_str(&r.message);
}
warnings = r.warnings;
}
Response { success: true, message: msg, warnings, errors: vec![] }
}