use cyberbrain_core::{Error, Result};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
pub const TOKEN_ENV: &str = "CYBERBRAIN_HUB_TOKEN";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Invitation {
pub kind: String,
pub version: u32,
pub device: String,
pub name: String,
pub token: String,
pub hub_url: Option<String>,
pub inference_url: Option<String>,
#[serde(default)]
pub hub_cert_sha256: Option<String>,
}
pub fn parse_invitation(text: &str) -> Result<Invitation> {
let inv: Invitation = serde_json::from_str(text)
.map_err(|e| Error::Config(format!("not an invitation file: {e}")))?;
if inv.kind != "cyberbrain.hub.invitation" {
return Err(Error::Config(format!(
"file says it is {:?}, not an invitation",
inv.kind
)));
}
if inv.version == 0 || inv.version > 2 {
return Err(Error::Config(format!(
"invitation version {} is newer than this program understands; upgrade it",
inv.version
)));
}
if inv.hub_url.is_none() {
return Err(Error::Config(
"the invitation names no hub address; ask for one issued with --hub-url".into(),
));
}
Ok(inv)
}
pub fn token_path(hub_url: &str) -> Option<PathBuf> {
let base = if cfg!(windows) {
std::env::var_os("APPDATA").map(PathBuf::from)
} else {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
}?;
let name = blake3::hash(hub_url.as_bytes()).to_hex().to_string();
Some(
base.join("cyberbrain")
.join("hub-tokens")
.join(format!("{}.token", &name[..32])),
)
}
pub fn token_for(hub_url: &str) -> Result<String> {
if let Ok(t) = std::env::var(TOKEN_ENV) {
let t = t.trim().to_string();
if !t.is_empty() {
return Ok(t);
}
}
let path = token_path(hub_url)
.ok_or_else(|| Error::Config("no configuration directory to read a token from".into()))?;
let text = std::fs::read_to_string(&path).map_err(|e| {
Error::Config(format!(
"no token for {hub_url}: {} ({e}). Enrol with `cyberbrain hub enrol <invitation>`, \
or set {TOKEN_ENV}",
path.display()
))
})?;
Ok(text.trim().to_string())
}
pub fn pin_path(hub_url: &str) -> Option<PathBuf> {
token_path(hub_url).map(|p| p.with_extension("pin"))
}
pub fn pin_for(hub_url: &str) -> Option<String> {
pin_at(&pin_path(hub_url)?)
}
pub fn pin_at(path: &std::path::Path) -> Option<String> {
let text = std::fs::read_to_string(path).ok()?;
let text = text.trim().to_string();
(!text.is_empty()).then_some(text)
}
pub fn save_pin(hub_url: &str, pin: &str) -> Result<PathBuf> {
let path = pin_path(hub_url)
.ok_or_else(|| Error::Config("no configuration directory to write a pin to".into()))?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| Error::Io {
path: parent.to_path_buf(),
source: e,
})?;
}
std::fs::write(&path, format!("{pin}\n")).map_err(|e| Error::Io {
path: path.clone(),
source: e,
})?;
Ok(path)
}
pub fn forget_pin(hub_url: &str) -> Result<()> {
if let Some(path) = pin_path(hub_url)
&& path.exists()
{
std::fs::remove_file(&path).map_err(|e| Error::Io { path, source: e })?;
}
Ok(())
}
pub fn save_token(hub_url: &str, token: &str) -> Result<PathBuf> {
let path = token_path(hub_url)
.ok_or_else(|| Error::Config("no configuration directory to write a token to".into()))?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| Error::Io {
path: parent.to_path_buf(),
source: e,
})?;
}
std::fs::write(&path, format!("{token}\n")).map_err(|e| Error::Io {
path: path.clone(),
source: e,
})?;
restrict(&path);
Ok(path)
}
fn restrict(path: &std::path::Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
}
#[cfg(not(unix))]
let _ = path;
}
#[derive(Debug, Clone, Serialize)]
pub struct Delivered {
pub accepted: usize,
pub total_rows: i64,
pub hub: String,
}
#[derive(Debug)]
pub enum Reply {
Ok(Delivered),
NotCollecting(String),
Gap {
expected: String,
},
Refused {
status: u16,
message: String,
},
}
pub async fn deliver(
egress: &cyberbrain_policy::Egress,
actor: &cyberbrain_policy::Actor,
hub_url: &str,
token: &str,
pin: Option<&str>,
version: &str,
bundle: String,
) -> Result<Reply> {
let url = format!("{}/api/v1/ingest", hub_url.trim_end_matches('/'));
let pin = pin
.map(cyberbrain_policy::egress::transport::CertificatePin::parse)
.transpose()?;
let ticket = egress.open(actor, cyberbrain_core::EgressPurpose::AuditSync, &url)?;
let resp = cyberbrain_policy::egress::transport::post_bearer(
&ticket,
&url,
token,
&[("x-cyberbrain-version", version)],
bundle,
pin,
)
.await?;
let body = String::from_utf8_lossy(&resp.body).to_string();
let json: serde_json::Value = serde_json::from_str(&body).unwrap_or(serde_json::Value::Null);
let message = json
.get("error")
.and_then(|v| v.as_str())
.unwrap_or(body.trim())
.to_string();
Ok(match resp.status {
200 => Reply::Ok(Delivered {
accepted: json
.get("accepted")
.and_then(|v| v.as_u64())
.unwrap_or_default() as usize,
total_rows: json
.get("total_rows")
.and_then(|v| v.as_i64())
.unwrap_or_default(),
hub: hub_url.to_string(),
}),
503 => Reply::NotCollecting(message),
409 => Reply::Gap {
expected: json
.get("expected_anchor")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
},
status => Reply::Refused { status, message },
})
}
pub fn set_hub_in_config(text: &str, url: &str, device: &str) -> String {
let mut out = String::with_capacity(text.len() + 128);
let mut in_hub = false;
let mut wrote_url = false;
let mut wrote_device = false;
for line in text.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with('[') {
if in_hub {
if !wrote_url {
out.push_str(&format!("url = \"{url}\"\n"));
wrote_url = true;
}
if !wrote_device {
out.push_str(&format!("device = \"{device}\"\n"));
wrote_device = true;
}
out.push('\n');
}
in_hub = trimmed.starts_with("[hub]");
out.push_str(line);
out.push('\n');
continue;
}
if in_hub {
let key = trimmed.trim_start_matches('#').trim_start();
if key.starts_with("url") && key.contains('=') {
out.push_str(&format!("url = \"{url}\"\n"));
wrote_url = true;
continue;
}
if key.starts_with("device") && key.contains('=') {
out.push_str(&format!("device = \"{device}\"\n"));
wrote_device = true;
continue;
}
}
out.push_str(line);
out.push('\n');
}
if in_hub {
if !wrote_url {
out.push_str(&format!("url = \"{url}\"\n"));
wrote_url = true;
}
if !wrote_device {
out.push_str(&format!("device = \"{device}\"\n"));
wrote_device = true;
}
}
if !wrote_url || !wrote_device {
out.push_str(&format!(
"\n[hub]\nurl = \"{url}\"\ndevice = \"{device}\"\n"
));
}
out
}
pub fn set_inference_url(text: &str, url: &str) -> String {
let mut out = String::with_capacity(text.len() + 64);
let mut in_inference = false;
let mut wrote = false;
for line in text.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with('[') {
if in_inference && !wrote {
out.push_str(&format!("base_url = \"{url}\"\n"));
wrote = true;
out.push('\n');
}
in_inference = trimmed.starts_with("[inference]");
out.push_str(line);
out.push('\n');
continue;
}
if in_inference {
let key = trimmed.trim_start_matches('#').trim_start();
if key.starts_with("base_url") && key.contains('=') {
out.push_str(&format!("base_url = \"{url}\"\n"));
wrote = true;
continue;
}
}
out.push_str(line);
out.push('\n');
}
if in_inference && !wrote {
out.push_str(&format!("base_url = \"{url}\"\n"));
wrote = true;
}
if !wrote {
out.push_str(&format!("\n[inference]\nbase_url = \"{url}\"\n"));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = "\
# A comment somebody wrote.
[rings]
resident_cap_tokens = 8192
[hub]
# Set by `cyberbrain hub enrol <invitation>`.
# url = \"https://example.invalid\"
allow_public_hub = false
[policy]
profile = \"eu\"
";
#[test]
fn enrolling_sets_the_url_and_keeps_every_comment() {
let out = set_hub_in_config(SAMPLE, "https://hub.internal:7788", "dev_1");
assert!(out.contains("# A comment somebody wrote."));
assert!(out.contains("# Set by `cyberbrain hub enrol <invitation>`."));
assert!(out.contains("url = \"https://hub.internal:7788\""));
assert!(out.contains("device = \"dev_1\""));
assert!(
out.contains("allow_public_hub = false"),
"other keys survive"
);
assert!(out.contains("profile = \"eu\""), "later sections survive");
assert!(!out.contains("https://example.invalid"));
let cfg: toml::Value = toml::from_str(&out).expect("valid toml");
assert_eq!(
cfg["hub"]["url"].as_str(),
Some("https://hub.internal:7788")
);
}
#[test]
fn enrolling_twice_does_not_duplicate_the_key() {
let once = set_hub_in_config(SAMPLE, "https://a.internal", "dev_1");
let twice = set_hub_in_config(&once, "https://b.internal", "dev_2");
assert_eq!(twice.matches("url = ").count(), 1);
assert_eq!(twice.matches("device = ").count(), 1);
assert!(twice.contains("https://b.internal"));
assert!(!twice.contains("https://a.internal"));
toml::from_str::<toml::Value>(&twice).expect("valid toml");
}
#[test]
fn a_config_without_a_hub_section_gets_one() {
let plain = "[rings]\nresident_cap_tokens = 8192\n";
let out = set_hub_in_config(plain, "https://hub.internal", "dev_9");
let cfg: toml::Value = toml::from_str(&out).expect("valid toml");
assert_eq!(cfg["hub"]["url"].as_str(), Some("https://hub.internal"));
assert_eq!(cfg["rings"]["resident_cap_tokens"].as_integer(), Some(8192));
}
#[test]
fn the_inference_endpoint_from_an_invitation_replaces_the_default() {
let text = "[inference]\nbase_url = \"http://127.0.0.1:11434/v1\"\ntimeout_ms = 30000\n";
let out = set_inference_url(text, "http://192.168.1.50:11434/v1");
let cfg: toml::Value = toml::from_str(&out).expect("valid toml");
assert_eq!(
cfg["inference"]["base_url"].as_str(),
Some("http://192.168.1.50:11434/v1")
);
assert_eq!(cfg["inference"]["timeout_ms"].as_integer(), Some(30000));
}
#[test]
fn an_invitation_must_say_which_hub() {
let without = r#"{"kind":"cyberbrain.hub.invitation","version":1,"device":"d","name":"n","token":"t","hub_url":null,"inference_url":null}"#;
let err = parse_invitation(without).unwrap_err().to_string();
assert!(err.contains("names no hub address"), "{err}");
}
#[test]
fn a_token_file_is_one_per_hub() {
let a = token_path("https://a.internal:7788");
let b = token_path("https://b.internal:7788");
assert_ne!(a, b, "two hubs, two tokens");
if let Some(p) = a {
let name = p.file_name().unwrap().to_string_lossy().to_string();
assert!(
!name.contains('/') && !name.contains(':'),
"the file name is a hash, not the URL: {name}"
);
}
}
}