use std::fs;
use std::io::{self, Write};
#[cfg(unix)]
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Default)]
struct Config {
#[serde(default)]
api_key: String,
#[serde(default, skip_serializing_if = "Monitoring::is_default")]
monitoring: Monitoring,
}
#[derive(Serialize, Deserialize, Default)]
struct Monitoring {
#[serde(default)]
host_id: String,
#[serde(default)]
prompt: String,
}
impl Monitoring {
fn is_default(&self) -> bool {
self.host_id.is_empty() && self.prompt.is_empty()
}
}
const DEFAULT_PROMPT_MODE: &str = "ask";
const ENV_KEY: &str = "OFFSEQ_API_KEY";
const CONFIG_DIR_ENV: &str = "OFFSEQ_CONFIG_DIR";
pub fn resolve_api_key(reset: bool, interactive: bool) -> Option<String> {
if let Ok(k) = std::env::var(ENV_KEY) {
let k = k.trim().to_string();
if !k.is_empty() {
return Some(k);
}
}
if !reset {
if let Some(cfg) = load_config() {
if !cfg.api_key.is_empty() {
return Some(cfg.api_key);
}
}
}
if interactive {
run_initial_setup();
return load_config().map(|c| c.api_key).filter(|k| !k.is_empty());
}
None
}
fn config_path() -> PathBuf {
let base = std::env::var_os(CONFIG_DIR_ENV)
.map(PathBuf::from)
.or_else(dirs::config_dir)
.unwrap_or_else(|| PathBuf::from("."));
base.join("offseq-rust").join("config.toml")
}
fn load_config() -> Option<Config> {
let path = config_path();
if !path.exists() {
return None;
}
let contents = fs::read_to_string(&path).ok()?;
toml::from_str(&contents).ok()
}
fn save_config(cfg: &Config) -> io::Result<()> {
let path = config_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
harden_dir(parent);
}
let toml_str = toml::to_string(cfg).map_err(io::Error::other)?;
let mut f = open_private(&path)?;
f.write_all(toml_str.as_bytes())?;
harden_file(&path)?;
Ok(())
}
#[cfg(unix)]
fn open_private(path: &Path) -> io::Result<fs::File> {
fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
}
#[cfg(not(unix))]
fn open_private(path: &Path) -> io::Result<fs::File> {
fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(path)
}
#[cfg(unix)]
fn harden_dir(dir: &Path) {
let _ = fs::set_permissions(dir, fs::Permissions::from_mode(0o700));
}
#[cfg(not(unix))]
fn harden_dir(_dir: &Path) {}
#[cfg(unix)]
fn harden_file(path: &Path) -> io::Result<()> {
fs::set_permissions(path, fs::Permissions::from_mode(0o600))
}
#[cfg(not(unix))]
fn harden_file(_path: &Path) -> io::Result<()> {
Ok(())
}
fn run_initial_setup() {
println!("\n┌─────────────────────────────────────────────────┐");
println!("│ OffSeq Threat Finder - Setup │");
println!("└─────────────────────────────────────────────────┘\n");
println!("No API key found. You need an OffSeq API key to continue.\n");
loop {
println!(" [1] I already have my API key");
println!(" [2] I need to get my API key\n");
print!("Select an option: ");
let _ = io::stdout().flush();
let choice = match read_line() {
Some(c) => c,
None => {
eprintln!("\nNo input received; aborting setup.");
return;
}
};
match choice.trim() {
"1" => {
let key = prompt_for_key();
let mut cfg = load_config().unwrap_or_default();
cfg.api_key = key;
match save_config(&cfg) {
Ok(_) => {
println!("\n✓ API key saved successfully.\n");
return;
}
Err(e) => {
eprintln!("\n[!] Failed to save config: {e}\n");
}
}
}
"2" => {
println!("\nYour API key is available at:");
println!(" https://radar.offseq.com/console\n");
println!("Once you have your key, select option 1 to continue.\n");
}
_ => {
println!("\nInvalid choice, please enter 1 or 2.\n");
}
}
}
}
fn prompt_for_key() -> String {
loop {
let key = rpassword::prompt_password("\nPaste your API key (input hidden): ")
.unwrap_or_default();
let key = key.trim().to_string();
if key.is_empty() {
println!("API key cannot be empty, please try again.");
continue;
}
if key.len() < 48 {
println!("That doesn't look like a valid API key (too short). Please try again.");
continue;
}
return key;
}
}
pub fn prompt_upgrade(detail: Option<&str>) {
println!("\n┌─────────────────────────────────────────────┐");
println!("│ Rate Limit Reached │");
println!("└─────────────────────────────────────────────┘\n");
println!("{}\n", upgrade_body_line(detail));
println!("To continue using OffSeq, upgrade your plan at:\n");
println!(" https://radar.offseq.com/pricing\n");
}
fn upgrade_body_line(detail: Option<&str>) -> &str {
match detail.map(str::trim).filter(|d| !d.is_empty()) {
Some(d) => d,
None => "You have exhausted the API calls available on your current plan.",
}
}
fn read_line() -> Option<String> {
let mut buf = String::new();
match io::stdin().read_line(&mut buf) {
Ok(0) => None,
Ok(_) => Some(buf),
Err(_) => None,
}
}
pub fn host_id() -> Option<String> {
load_config()
.map(|cfg| cfg.monitoring.host_id)
.filter(|id| !id.is_empty())
}
pub fn get_or_create_host_id() -> String {
let mut cfg = load_config().unwrap_or_default();
if !cfg.monitoring.host_id.is_empty() {
return cfg.monitoring.host_id;
}
let id = uuid::Uuid::new_v4().to_string();
cfg.monitoring.host_id = id.clone();
let _ = save_config(&cfg);
id
}
pub fn monitoring_prompt_mode() -> String {
match load_config() {
Some(cfg) if !cfg.monitoring.prompt.is_empty() => cfg.monitoring.prompt,
_ => DEFAULT_PROMPT_MODE.to_string(),
}
}
pub fn set_monitoring_prompt_mode(mode: &str) -> io::Result<()> {
let mut cfg = load_config().unwrap_or_default();
cfg.monitoring.prompt = mode.to_string();
save_config(&cfg)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
#[test]
fn upgrade_body_prefers_server_detail() {
assert_eq!(
upgrade_body_line(Some("Hourly rate limit reached. Resets at 14:00 UTC. Upgrade your plan for higher limits.")),
"Hourly rate limit reached. Resets at 14:00 UTC. Upgrade your plan for higher limits."
);
assert_eq!(upgrade_body_line(Some(" Monthly quota used. ")), "Monthly quota used.");
assert_eq!(
upgrade_body_line(None),
"You have exhausted the API calls available on your current plan."
);
assert_eq!(
upgrade_body_line(Some(" ")),
"You have exhausted the API calls available on your current plan."
);
}
static ENV_LOCK: Mutex<()> = Mutex::new(());
struct TempConfigHome {
dir: PathBuf,
prev: Option<std::ffi::OsString>,
}
impl TempConfigHome {
fn new() -> Self {
let dir = std::env::temp_dir().join(format!("tf-test-{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&dir).unwrap();
let prev = std::env::var_os(CONFIG_DIR_ENV);
std::env::set_var(CONFIG_DIR_ENV, &dir);
TempConfigHome { dir, prev }
}
}
impl Drop for TempConfigHome {
fn drop(&mut self) {
match &self.prev {
Some(v) => std::env::set_var(CONFIG_DIR_ENV, v),
None => std::env::remove_var(CONFIG_DIR_ENV),
}
let _ = fs::remove_dir_all(&self.dir);
}
}
#[test]
fn host_id_generates_persists_and_reloads() {
let _g = ENV_LOCK.lock().unwrap();
let _tmp = TempConfigHome::new();
let id1 = get_or_create_host_id();
assert_eq!(id1.len(), 36, "v4 UUID string");
let id2 = get_or_create_host_id();
assert_eq!(id1, id2, "host id must be stable once generated");
let cfg = load_config().expect("config persisted");
assert_eq!(cfg.monitoring.host_id, id1);
}
#[test]
fn host_id_read_only_returns_none_without_persisting() {
let _g = ENV_LOCK.lock().unwrap();
let _tmp = TempConfigHome::new();
assert_eq!(host_id(), None, "unset host id reads back as None");
assert!(load_config().is_none(), "host_id() must not create a config");
let id = get_or_create_host_id();
assert_eq!(host_id().as_deref(), Some(id.as_str()));
}
#[test]
fn prompt_mode_defaults_to_ask_and_round_trips() {
let _g = ENV_LOCK.lock().unwrap();
let _tmp = TempConfigHome::new();
assert_eq!(monitoring_prompt_mode(), "ask", "default when unset");
set_monitoring_prompt_mode("never").unwrap();
assert_eq!(monitoring_prompt_mode(), "never");
let id = get_or_create_host_id();
set_monitoring_prompt_mode("always").unwrap();
assert_eq!(monitoring_prompt_mode(), "always");
assert_eq!(get_or_create_host_id(), id, "host id preserved across prompt writes");
}
}