use std::time::Duration;
use serde::Deserialize;
use crate::config::SensitiveString;
use crate::publish::error::PublishError;
pub const DEFAULT_PUBLISH_URL: &str = "https://api.talek.cloud/v1/runs";
pub const MAX_PAYLOAD_BYTES: usize = 5 * 1024 * 1024;
pub const DEFAULT_PUBLISH_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, Deserialize, Default, PartialEq)]
pub struct PublishConfigYaml {
pub enabled: Option<bool>,
pub url: Option<String>,
}
#[derive(Debug, Clone)]
pub struct PublishConfig {
pub base_url: String,
pub api_key: Option<SensitiveString>,
pub timeout: Duration,
}
#[derive(Debug, Default)]
pub struct PublishConfigBuilder {
pub env_api_key: Option<String>,
pub yaml: Option<PublishConfigYaml>,
pub timeout: Option<Duration>,
}
impl PublishConfigBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn build(self) -> Result<PublishConfig, PublishError> {
let raw_url = self
.yaml
.as_ref()
.and_then(|y| y.url.clone())
.unwrap_or_else(|| DEFAULT_PUBLISH_URL.to_string());
let base_url = normalize_and_validate_url(&raw_url)?;
let api_key = self
.env_api_key
.filter(|s| !s.trim().is_empty())
.map(SensitiveString::new);
Ok(PublishConfig {
base_url,
api_key,
timeout: self.timeout.unwrap_or(DEFAULT_PUBLISH_TIMEOUT),
})
}
}
pub fn normalize_and_validate_url(raw: &str) -> Result<String, PublishError> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(PublishError::InvalidUrl("URL is empty".into()));
}
let parsed = reqwest::Url::parse(trimmed)
.map_err(|e| PublishError::InvalidUrl(format!("could not parse '{trimmed}': {e}")))?;
let scheme = parsed.scheme();
let is_https = scheme == "https";
let is_http = scheme == "http";
if !is_https && !is_http {
return Err(PublishError::InvalidUrl(format!(
"scheme '{scheme}' is not supported — use https:// (or http:// for localhost only)"
)));
}
let host = parsed.host_str().unwrap_or("");
let is_loopback = matches!(host, "localhost" | "127.0.0.1" | "::1" | "[::1]");
if is_http && !is_loopback {
return Err(PublishError::InvalidUrl(format!(
"HTTPS is required for non-loopback hosts; got http://{host} — \
use https:// or an allowed loopback address (localhost, 127.0.0.1, ::1)"
)));
}
if parsed.query().is_some() {
return Err(PublishError::InvalidUrl(
"URL must not contain a query string".into(),
));
}
if parsed.fragment().is_some() {
return Err(PublishError::InvalidUrl(
"URL must not contain a fragment".into(),
));
}
if !parsed.username().is_empty() || parsed.password().is_some() {
return Err(PublishError::InvalidUrl(
"URL must not contain userinfo (user:password@...)".into(),
));
}
let mut normalized = parsed.to_string();
if normalized.ends_with('/') {
normalized.pop();
}
Ok(normalized)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_https_url() {
let u = normalize_and_validate_url("https://api.talek.cloud").unwrap();
assert_eq!(u, "https://api.talek.cloud");
}
#[test]
fn strips_trailing_slash() {
let u = normalize_and_validate_url("https://api.talek.cloud/").unwrap();
assert_eq!(u, "https://api.talek.cloud");
}
#[test]
fn allows_http_localhost() {
let u = normalize_and_validate_url("http://localhost:3000").unwrap();
assert_eq!(u, "http://localhost:3000");
}
#[test]
fn allows_http_127() {
let u = normalize_and_validate_url("http://127.0.0.1:8080").unwrap();
assert_eq!(u, "http://127.0.0.1:8080");
}
#[test]
fn rejects_http_public_host() {
let err = normalize_and_validate_url("http://api.example.com").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("HTTPS"), "expected HTTPS error, got: {msg}");
}
#[test]
fn rejects_bad_scheme() {
let err = normalize_and_validate_url("ftp://example.com").unwrap_err();
assert!(err.to_string().contains("scheme"));
}
#[test]
fn accepts_url_with_path() {
let u = normalize_and_validate_url("https://api.talek.cloud/v1/runs").unwrap();
assert_eq!(u, "https://api.talek.cloud/v1/runs");
}
#[test]
fn accepts_url_with_nested_path() {
let u = normalize_and_validate_url("https://api.example.com/some/custom/path").unwrap();
assert_eq!(u, "https://api.example.com/some/custom/path");
}
#[test]
fn rejects_url_with_query() {
let err = normalize_and_validate_url("https://api.talek.cloud?x=1").unwrap_err();
assert!(err.to_string().contains("query"));
}
#[test]
fn rejects_url_with_userinfo() {
let err = normalize_and_validate_url("https://user:pass@api.talek.cloud").unwrap_err();
assert!(err.to_string().contains("userinfo"));
}
#[test]
fn rejects_empty_url() {
let err = normalize_and_validate_url("").unwrap_err();
assert!(err.to_string().contains("empty"));
}
#[test]
fn rejects_malformed_url() {
let err = normalize_and_validate_url("not a url").unwrap_err();
assert!(err.to_string().contains("parse"));
}
#[test]
fn trims_whitespace() {
let u = normalize_and_validate_url(" https://api.talek.cloud ").unwrap();
assert_eq!(u, "https://api.talek.cloud");
}
#[test]
fn builder_default_url_when_none_configured() {
let result = PublishConfigBuilder::new().build().unwrap();
assert_eq!(result.base_url, DEFAULT_PUBLISH_URL);
}
#[test]
fn builder_yaml_url_wins_over_default() {
let result = PublishConfigBuilder {
yaml: Some(PublishConfigYaml {
enabled: Some(true),
url: Some("https://yaml.example.com".into()),
}),
..Default::default()
}
.build()
.unwrap();
assert_eq!(result.base_url, "https://yaml.example.com");
}
#[test]
fn builder_api_key_set() {
let result = PublishConfigBuilder {
env_api_key: Some("test-key".into()),
..Default::default()
}
.build()
.unwrap();
assert!(result.api_key.is_some());
}
#[test]
fn builder_missing_api_key_returns_none_key() {
let result = PublishConfigBuilder::new().build().unwrap();
assert!(result.api_key.is_none());
}
#[test]
fn builder_empty_api_key_returns_none_key() {
let result = PublishConfigBuilder {
env_api_key: Some(" ".into()),
..Default::default()
}
.build()
.unwrap();
assert!(result.api_key.is_none());
}
#[test]
fn builder_invalid_url_errors() {
let err = PublishConfigBuilder {
yaml: Some(PublishConfigYaml {
enabled: Some(true),
url: Some("http://public.example.com".into()),
}),
..Default::default()
}
.build()
.unwrap_err();
assert!(matches!(err, PublishError::InvalidUrl(_)));
}
#[test]
fn parse_publish_yaml_full() {
let yaml = "enabled: true\nurl: https://api.example.com\n";
let parsed: PublishConfigYaml = serde_norway::from_str(yaml).unwrap();
assert_eq!(parsed.enabled, Some(true));
assert_eq!(parsed.url.as_deref(), Some("https://api.example.com"));
}
#[test]
fn parse_publish_yaml_enabled_only() {
let yaml = "enabled: true\n";
let parsed: PublishConfigYaml = serde_norway::from_str(yaml).unwrap();
assert_eq!(parsed.enabled, Some(true));
assert!(parsed.url.is_none());
}
#[test]
fn parse_publish_yaml_empty() {
let yaml = "{}";
let parsed: PublishConfigYaml = serde_norway::from_str(yaml).unwrap();
assert!(parsed.enabled.is_none());
assert!(parsed.url.is_none());
}
}