use anyhow::{Context, Result};
use base64::Engine;
use serde::Deserialize;
use std::{
collections::BTreeMap,
ffi::{CStr, CString, OsString},
fs::OpenOptions,
io::Write,
os::unix::{
ffi::{OsStrExt, OsStringExt},
fs::{MetadataExt, PermissionsExt},
},
path::{Path, PathBuf},
};
#[derive(Clone)]
pub struct Config {
pub bind: String,
pub history_file: PathBuf,
pub timeout: String,
pub projects: BTreeMap<String, Project>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct GlobalConfig {
#[serde(default = "default_bind")]
bind: String,
#[serde(default = "default_history")]
history_file: PathBuf,
#[serde(default = "default_timeout")]
timeout: String,
}
impl Default for GlobalConfig {
fn default() -> Self {
Self {
bind: default_bind(),
history_file: default_history(),
timeout: default_timeout(),
}
}
}
#[derive(Deserialize)]
struct FileConfig {
#[serde(default)]
blip: GlobalConfig,
#[serde(flatten)]
tables: BTreeMap<String, toml::Value>,
}
impl<'de> Deserialize<'de> for Config {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = FileConfig::deserialize(deserializer)?;
let mut global = raw.blip;
let mut projects = BTreeMap::new();
for (key, value) in raw.tables {
let is_project = value
.as_table()
.is_some_and(|table| table.contains_key("script"));
if key == "bind" && value.is_str() {
global.bind = value.try_into().map_err(serde::de::Error::custom)?;
} else if key == "history_file" && value.is_str() {
global.history_file = value.try_into().map_err(serde::de::Error::custom)?;
} else if (key == "timeout" || key == "execution_timeout") && value.is_str() {
global.timeout = value.try_into().map_err(serde::de::Error::custom)?;
} else if key == "execution_timeout_seconds" && value.is_integer() {
let seconds: u64 = value.try_into().map_err(serde::de::Error::custom)?;
global.timeout = format!("{seconds}s");
} else if key == "projects" && !is_project {
let legacy: BTreeMap<String, Project> =
value.try_into().map_err(serde::de::Error::custom)?;
for (legacy_key, mut project) in legacy {
if project.name.is_empty() {
project.name = legacy_key.clone();
}
projects.insert(legacy_key, project);
}
} else {
let mut project: Project = value.try_into().map_err(serde::de::Error::custom)?;
if project.name.is_empty() {
project.name = key.clone();
}
projects.insert(key, project);
}
}
Ok(Self {
bind: global.bind,
history_file: global.history_file,
timeout: global.timeout,
projects,
})
}
}
impl Default for Config {
fn default() -> Self {
Self {
bind: default_bind(),
history_file: default_history(),
timeout: default_timeout(),
projects: BTreeMap::new(),
}
}
}
#[derive(Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Project {
#[serde(default)]
pub name: String,
pub script: PathBuf,
#[serde(default)]
pub timeout: Option<String>,
#[serde(default)]
pub gitlab: Option<GitlabTemplate>,
#[serde(default)]
pub github: Option<GithubTemplate>,
#[serde(default)]
pub gitea: Option<GiteaTemplate>,
#[serde(default)]
pub codeberg: Option<CodebergTemplate>,
}
#[derive(Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GitlabTemplate {
#[serde(default)]
pub signing_token: Option<String>,
#[serde(default)]
pub secret_token: Option<String>,
#[serde(default = "default_timestamp_tolerance")]
pub timestamp_tolerance_seconds: i64,
}
#[derive(Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GithubTemplate {
pub secret: String,
}
#[derive(Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GiteaTemplate {
pub secret: String,
}
#[derive(Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CodebergTemplate {
pub secret: String,
}
impl Project {
pub fn provider_name(&self) -> &'static str {
if self.gitlab.is_some() {
"gitlab"
} else if self.github.is_some() {
"github"
} else if self.gitea.is_some() {
"gitea"
} else if self.codeberg.is_some() {
"codeberg"
} else {
"unconfigured"
}
}
pub fn timeout_seconds(&self, global: &str) -> Result<u64> {
parse_duration(self.timeout.as_deref().unwrap_or(global))
}
}
pub fn default_bind() -> String {
"127.0.0.1:8080".into()
}
pub fn default_history() -> PathBuf {
"blip-history.jsonl".into()
}
pub fn default_timestamp_tolerance() -> i64 {
300
}
pub fn default_timeout() -> String {
"1h".into()
}
pub fn parse_duration(value: &str) -> Result<u64> {
let value = value.trim();
let split = value
.find(|character: char| !character.is_ascii_digit())
.context("timeout must contain a number and unit")?;
let (number, unit) = value.split_at(split);
if number.is_empty() || unit.len() != 1 {
anyhow::bail!("timeout must use one unit: s, m, h, or d (for example 70s, 21m, 6h, or 2d)");
}
let amount: u64 = number.parse().context("timeout number is invalid")?;
if amount == 0 {
anyhow::bail!("timeout must be greater than zero");
}
let multiplier = match unit.as_bytes()[0] {
b's' => 1,
b'm' => 60,
b'h' => 60 * 60,
b'd' => 24 * 60 * 60,
_ => anyhow::bail!("timeout unit must be s, m, h, or d"),
};
amount
.checked_mul(multiplier)
.context("timeout is too large")
}
pub fn validate_duration(value: &str) -> Result<()> {
parse_duration(value).map(|_| ())
}
pub fn resolve_path(requested: Option<PathBuf>) -> PathBuf {
requested.unwrap_or_else(|| default_data_dir().join("blip.toml"))
}
pub fn migrate_legacy_layout(path: &Path) -> Result<bool> {
if unsafe { libc::geteuid() } != 0 {
return Ok(false);
}
if path.exists() || path != default_data_dir().join("blip.toml") {
return Ok(false);
}
let legacy_config = Path::new("/etc/blip/blip.toml");
if !legacy_config.is_file() {
return Ok(false);
}
migrate_layout(legacy_config, Path::new("/var/lib/blip"), path)
}
fn migrate_layout(legacy_config: &Path, legacy_runtime: &Path, path: &Path) -> Result<bool> {
let parent = path.parent().context("configuration path has no parent")?;
std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
let stamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ");
let backup = legacy_config.with_extension(format!("toml.{stamp}.migrated.bak"));
std::fs::copy(legacy_config, &backup).context("back up legacy configuration")?;
let mut text = std::fs::read_to_string(legacy_config).context("read legacy configuration")?;
text = text.replace("/var/lib/blip/blip-history.jsonl", "blip-history.jsonl");
let mut destination = OpenOptions::new()
.create_new(true)
.write(true)
.open(path)
.with_context(|| format!("create {}", path.display()))?;
destination.set_permissions(std::fs::Permissions::from_mode(0o600))?;
destination.write_all(text.as_bytes())?;
destination.sync_all()?;
for name in [
"blip-history.jsonl",
"blip-deliveries.jsonl",
"blip.queue.lock",
] {
let old = legacy_runtime.join(name);
let new = parent.join(name);
if old.exists() && !new.exists() {
std::fs::copy(&old, &new).with_context(|| format!("migrate {name}"))?;
}
}
Ok(true)
}
pub fn default_data_dir() -> PathBuf {
effective_home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".local/share/blip")
}
fn effective_home_dir() -> Option<PathBuf> {
if unsafe { libc::geteuid() } == 0 {
if let Some(home) = std::env::var_os("SUDO_USER").and_then(user_home_dir) {
return Some(home);
}
}
std::env::var_os("HOME").map(PathBuf::from)
}
fn user_home_dir(username: OsString) -> Option<PathBuf> {
let username = CString::new(username.as_os_str().as_bytes()).ok()?;
let entry = unsafe { libc::getpwnam(username.as_ptr()) };
if entry.is_null() {
return None;
}
let directory = unsafe { CStr::from_ptr((*entry).pw_dir) };
Some(PathBuf::from(OsString::from_vec(
directory.to_bytes().to_vec(),
)))
}
pub fn load(path: &Path) -> Result<Config> {
let text =
std::fs::read_to_string(path).with_context(|| format!("read config {}", path.display()))?;
let mut config: Config = toml::from_str(&text).context("parse TOML")?;
config.history_file = resolve_history_file(path, &config.history_file);
validate(&config)?;
Ok(config)
}
pub fn load_or_default(path: &Path) -> Result<Config> {
if path.exists() {
load(path)
} else {
let mut config = Config::default();
config.history_file = resolve_history_file(path, &config.history_file);
Ok(config)
}
}
fn resolve_history_file(config_path: &Path, history_file: &Path) -> PathBuf {
if history_file.is_absolute() {
return history_file.to_path_buf();
}
match config_path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
{
Some(parent) => parent.join(history_file),
None => history_file.to_path_buf(),
}
}
pub fn validate(config: &Config) -> Result<()> {
if config.bind.trim().is_empty() {
anyhow::bail!("bind cannot be empty");
}
if config.history_file.as_os_str().is_empty() {
anyhow::bail!("history_file cannot be empty");
}
validate_duration(&config.timeout)
.context("timeout must use a value such as 70s, 21m, 6h, or 2d")?;
for (key, project) in &config.projects {
if !valid_project_key(key) {
anyhow::bail!(
"project key {key:?} must contain only letters, digits, dots, underscores, or hyphens"
);
}
if !project.script.is_absolute() {
anyhow::bail!("project {key:?} script must be an absolute file path");
}
if let Some(timeout) = &project.timeout {
validate_duration(timeout).with_context(|| {
format!("project {key:?} timeout must use a value such as 70s, 21m, 6h, or 2d")
})?;
}
let metadata = std::fs::metadata(&project.script)
.with_context(|| format!("project {key:?} script {}", project.script.display()))?;
if !metadata.is_file() {
anyhow::bail!("project {key:?} script must be a file");
}
if metadata.permissions().mode() & 0o111 == 0 {
anyhow::bail!("project {key:?} script is not executable");
}
let provider_count = [
project.gitlab.is_some(),
project.github.is_some(),
project.gitea.is_some(),
project.codeberg.is_some(),
]
.into_iter()
.filter(|configured| *configured)
.count();
if provider_count != 1 {
anyhow::bail!("project {key:?} must configure exactly one provider template");
}
if let Some(template) = &project.gitlab {
if template.timestamp_tolerance_seconds <= 0 {
anyhow::bail!("project {key:?} GitLab timestamp tolerance must be positive");
}
if template.secret_token.as_deref().is_some_and(str::is_empty) {
anyhow::bail!("project {key:?} GitLab Secret token cannot be empty");
}
match template.signing_token.as_deref() {
Some(token) => {
decode_signing_token(token)
.with_context(|| format!("project {key:?} GitLab Signing token"))?;
}
None if template.secret_token.is_none() => {
anyhow::bail!(
"project {key:?} requires a GitLab Signing token or Secret token"
);
}
None => {}
}
}
for (provider, secret) in [
("GitHub", project.github.as_ref().map(|value| &value.secret)),
("Gitea", project.gitea.as_ref().map(|value| &value.secret)),
(
"Codeberg",
project.codeberg.as_ref().map(|value| &value.secret),
),
] {
if secret.is_some_and(|value| value.is_empty()) {
anyhow::bail!("project {key:?} {provider} secret cannot be empty");
}
}
}
Ok(())
}
pub fn valid_project_key(key: &str) -> bool {
!key.is_empty()
&& key.len() <= 64
&& key
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
}
pub fn decode_signing_token(token: &str) -> Result<Vec<u8>> {
let encoded = token
.strip_prefix("whsec_")
.context("must start with whsec_")?;
let key = base64::engine::general_purpose::STANDARD
.decode(encoded)
.context("must contain valid base64 after whsec_")?;
if key.is_empty() {
anyhow::bail!("must contain a non-empty key");
}
Ok(key)
}
pub fn render(config: &Config, reveal_secrets: bool) -> String {
let mut output = String::new();
output.push_str("[blip]\n");
output.push_str(&format!("bind = \"{}\"\n", escape(&config.bind)));
output.push_str(&format!(
"history_file = \"{}\"\n",
escape(&config.history_file.to_string_lossy())
));
output.push_str(&format!("timeout = \"{}\"\n", escape(&config.timeout)));
for (key, project) in &config.projects {
output.push_str(&format!("\n[{key}]\n"));
output.push_str(&format!("name = \"{}\"\n", escape(&project.name)));
output.push_str(&format!(
"script = \"{}\"\n",
escape(&project.script.to_string_lossy())
));
if let Some(timeout) = &project.timeout {
output.push_str(&format!("timeout = \"{}\"\n", escape(timeout)));
}
if let Some(template) = &project.gitlab {
if let Some(token) = &template.signing_token {
let value = if reveal_secrets { token } else { "<redacted>" };
output.push_str(&format!("gitlab.signing_token = \"{}\"\n", escape(value)));
}
if let Some(token) = &template.secret_token {
let value = if reveal_secrets { token } else { "<redacted>" };
output.push_str(&format!("gitlab.secret_token = \"{}\"\n", escape(value)));
}
if template.timestamp_tolerance_seconds != default_timestamp_tolerance() {
output.push_str(&format!(
"gitlab.timestamp_tolerance_seconds = {}\n",
template.timestamp_tolerance_seconds
));
}
}
for (provider, secret) in [
("github", project.github.as_ref().map(|value| &value.secret)),
("gitea", project.gitea.as_ref().map(|value| &value.secret)),
(
"codeberg",
project.codeberg.as_ref().map(|value| &value.secret),
),
] {
if let Some(secret) = secret {
let value = if reveal_secrets { secret } else { "<redacted>" };
output.push_str(&format!("{provider}.secret = \"{}\"\n", escape(value)));
}
}
}
output
}
pub fn save(path: &Path, config: &Config) -> Result<()> {
validate(config)?;
let parent = path.parent().filter(|value| !value.as_os_str().is_empty());
if let Some(parent) = parent {
if !parent.exists() {
anyhow::bail!("config directory does not exist: {}", parent.display());
}
}
let existing = std::fs::metadata(path).ok();
let mode = existing
.as_ref()
.map(|metadata| metadata.permissions().mode())
.unwrap_or(0o600);
let temporary = path.with_extension(format!("tmp.{}", std::process::id()));
let result = (|| -> Result<()> {
let mut file = OpenOptions::new()
.create_new(true)
.write(true)
.open(&temporary)
.with_context(|| format!("create temporary config {}", temporary.display()))?;
file.set_permissions(std::fs::Permissions::from_mode(mode))?;
file.write_all(render(config, true).as_bytes())?;
file.sync_all()?;
if unsafe { libc::geteuid() } == 0 {
if let Some(metadata) = &existing {
let path_bytes = CString::new(temporary.as_os_str().as_bytes())?;
let result =
unsafe { libc::chown(path_bytes.as_ptr(), metadata.uid(), metadata.gid()) };
if result != 0 {
return Err(std::io::Error::last_os_error())
.context("preserve config ownership");
}
}
}
std::fs::rename(&temporary, path)
.with_context(|| format!("replace config {}", path.display()))?;
Ok(())
})();
if result.is_err() {
let _ = std::fs::remove_file(&temporary);
}
result
}
fn escape(value: &str) -> String {
value
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\t', "\\t")
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_config() -> Config {
let mut config = Config::default();
config.projects.insert(
"example-app".into(),
Project {
name: "example-app".into(),
script: PathBuf::from("/bin/true"),
timeout: None,
gitlab: Some(GitlabTemplate {
signing_token: None,
secret_token: Some("test-secret".into()),
timestamp_tolerance_seconds: 300,
}),
github: None,
gitea: None,
codeberg: None,
},
);
config
}
#[test]
fn rendering_writes_the_project_key_once() {
let rendered = render(&sample_config(), true);
assert_eq!(rendered.matches("[example-app]").count(), 1);
assert!(!rendered.contains("[example-app.gitlab]"));
assert!(rendered.contains("gitlab.secret_token = \"test-secret\""));
}
#[test]
fn current_schema_uses_top_level_ids_and_separate_names() {
let config = toml::from_str::<Config>(
r#"
[blip]
timeout = "21m"
[app-prod]
name = "Production API"
script = "/bin/true"
timeout = "70s"
github.secret = "test-secret"
"#,
)
.unwrap();
assert_eq!(config.timeout, "21m");
assert_eq!(config.projects["app-prod"].name, "Production API");
assert_eq!(config.projects["app-prod"].timeout.as_deref(), Some("70s"));
validate(&config).unwrap();
}
#[test]
fn project_ids_may_match_legacy_global_field_names() {
let config = toml::from_str::<Config>(
r#"
[blip]
timeout = "5s"
[timeout]
name = "Timeout test"
script = "/bin/true"
gitlab.secret_token = "test-secret"
[projects]
name = "Projects test"
script = "/bin/true"
github.secret = "test-secret"
"#,
)
.unwrap();
assert_eq!(config.timeout, "5s");
assert_eq!(config.projects["timeout"].name, "Timeout test");
assert_eq!(config.projects["projects"].name, "Projects test");
validate(&config).unwrap();
}
#[test]
fn timeout_accepts_human_units() {
assert_eq!(parse_duration("70s").unwrap(), 70);
assert_eq!(parse_duration("21m").unwrap(), 21 * 60);
assert_eq!(parse_duration("6h").unwrap(), 6 * 60 * 60);
assert_eq!(parse_duration("2d").unwrap(), 2 * 24 * 60 * 60);
assert!(parse_duration("1w").is_err());
assert!(parse_duration("0s").is_err());
}
#[test]
fn rendering_redacts_credentials_by_default() {
let rendered = render(&sample_config(), false);
assert!(!rendered.contains("test-secret"));
assert!(rendered.contains("gitlab.secret_token = \"<redacted>\""));
}
#[test]
fn saved_config_round_trips() {
let path = std::env::temp_dir().join(format!(
"blip-config-test-{}-{}.toml",
std::process::id(),
chrono::Utc::now().timestamp_nanos_opt().unwrap()
));
save(&path, &sample_config()).unwrap();
let loaded = load(&path).unwrap();
assert!(loaded.projects.contains_key("example-app"));
std::fs::remove_file(path).unwrap();
}
#[test]
fn relative_history_is_resolved_beside_its_configuration() {
assert_eq!(
resolve_history_file(
Path::new("/home/deploy/.local/share/blip/blip.toml"),
Path::new("blip-history.jsonl"),
),
PathBuf::from("/home/deploy/.local/share/blip/blip-history.jsonl")
);
assert_eq!(
resolve_history_file(
Path::new("/srv/blip/config/blip.toml"),
Path::new("runtime/history.jsonl"),
),
PathBuf::from("/srv/blip/config/runtime/history.jsonl")
);
assert_eq!(
resolve_history_file(
Path::new("/home/deploy/.local/share/blip/blip.toml"),
Path::new("/data/blip/history.jsonl"),
),
PathBuf::from("/data/blip/history.jsonl")
);
}
#[test]
fn legacy_migration_copies_runtime_data_and_secures_config() {
let root = std::env::temp_dir().join(format!(
"blip-migration-test-{}-{}",
std::process::id(),
chrono::Utc::now().timestamp_nanos_opt().unwrap()
));
let legacy_dir = root.join("etc");
let runtime_dir = root.join("var");
let destination = root.join("data/blip.toml");
std::fs::create_dir_all(&legacy_dir).unwrap();
std::fs::create_dir_all(&runtime_dir).unwrap();
let legacy = legacy_dir.join("blip.toml");
std::fs::write(
&legacy,
"history_file = \"/var/lib/blip/blip-history.jsonl\"\n",
)
.unwrap();
std::fs::write(runtime_dir.join("blip-history.jsonl"), "history\n").unwrap();
assert!(migrate_layout(&legacy, &runtime_dir, &destination).unwrap());
let migrated = std::fs::read_to_string(&destination).unwrap();
assert!(migrated.contains("history_file = \"blip-history.jsonl\""));
assert_eq!(
std::fs::metadata(&destination)
.unwrap()
.permissions()
.mode()
& 0o777,
0o600
);
assert_eq!(
std::fs::read_to_string(root.join("data/blip-history.jsonl")).unwrap(),
"history\n"
);
assert!(std::fs::read_dir(&legacy_dir).unwrap().any(|entry| entry
.unwrap()
.file_name()
.to_string_lossy()
.contains("migrated.bak")));
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn projects_require_exactly_one_provider_template() {
let missing = toml::from_str::<Config>(
r#"
[projects.app]
script = "/bin/true"
"#,
)
.unwrap();
assert!(validate(&missing).is_err());
let multiple = toml::from_str::<Config>(
r#"
[projects.app]
script = "/bin/true"
github.secret = "one"
gitea.secret = "two"
"#,
)
.unwrap();
assert!(validate(&multiple).is_err());
}
#[test]
fn provider_templates_round_trip_and_redact_secrets() {
let config = toml::from_str::<Config>(
r#"
[projects.github]
script = "/bin/true"
github.secret = "github-secret"
[projects.gitea]
script = "/bin/true"
gitea.secret = "gitea-secret"
[projects.codeberg]
script = "/bin/true"
codeberg.secret = "codeberg-secret"
"#,
)
.unwrap();
validate(&config).unwrap();
let redacted = render(&config, false);
assert!(!redacted.contains("github-secret"));
assert!(!redacted.contains("gitea-secret"));
assert!(!redacted.contains("codeberg-secret"));
assert_eq!(redacted.matches("<redacted>").count(), 3);
let revealed = render(&config, true);
let reparsed = toml::from_str::<Config>(&revealed).unwrap();
validate(&reparsed).unwrap();
}
}