use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
struct Req {
method: String,
target: String,
host: Option<String>,
origin: Option<String>,
fetch_site: Option<String>,
guard: bool,
body: String,
raw: Vec<u8>,
}
const MAX_HEAD_BYTES: u64 = 64 * 1024;
const MAX_HEADERS: usize = 200;
const MAX_BODY_BYTES: usize = 256 * 1024;
const MAX_SEND_BYTES: usize = 12 * 1024 * 1024;
const IO_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
fn read_req(stream: &TcpStream) -> std::io::Result<Req> {
stream.set_read_timeout(Some(IO_TIMEOUT))?;
stream.set_write_timeout(Some(IO_TIMEOUT))?;
let mut reader = BufReader::new(stream.try_clone()?.take(MAX_HEAD_BYTES));
let mut line = String::new();
reader.read_line(&mut line)?;
let mut parts = line.split_whitespace();
let method = parts.next().unwrap_or("").to_string();
let target = parts.next().unwrap_or("/").to_string();
let cap = if target == "/api/send" {
MAX_SEND_BYTES
} else {
MAX_BODY_BYTES
};
let mut host = None;
let mut origin = None;
let mut fetch_site = None;
let mut guard = false;
let mut len = 0usize;
let mut seen = 0usize;
loop {
seen += 1;
if seen > MAX_HEADERS {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"too many request headers",
));
}
let mut h = String::new();
if reader.read_line(&mut h)? == 0 {
break;
}
let h = h.trim_end();
if h.is_empty() {
break;
}
let Some((k, v)) = h.split_once(':') else {
continue;
};
let (k, v) = (k.trim().to_ascii_lowercase(), v.trim().to_string());
match k.as_str() {
"host" => host = Some(v.to_ascii_lowercase()),
"origin" => origin = Some(v),
"sec-fetch-site" => fetch_site = Some(v),
"x-gage" => guard = true,
"content-length" => len = v.parse().unwrap_or(0).min(cap),
_ => {}
}
}
reader.get_mut().set_limit(len as u64);
let mut body = vec![0u8; len];
if len > 0 {
reader.read_exact(&mut body)?;
}
Ok(Req {
host,
method,
target,
origin,
fetch_site,
guard,
body: String::from_utf8_lossy(&body).to_string(),
raw: body,
})
}
fn host_is_local(r: &Req, port: u16) -> bool {
let Some(h) = &r.host else { return false };
[
format!("127.0.0.1:{port}"),
format!("localhost:{port}"),
format!("[::1]:{port}"),
]
.iter()
.any(|x| x == h)
}
fn may_mutate(r: &Req, port: u16) -> bool {
if !r.guard {
return false;
}
if let Some(site) = &r.fetch_site
&& site != "same-origin"
&& site != "none"
{
return false;
}
let Some(o) = &r.origin else {
return false;
};
let ok = [
format!("http://127.0.0.1:{port}"),
format!("http://localhost:{port}"),
format!("http://[::1]:{port}"),
];
ok.iter().any(|x| x == o)
}
fn background_pull() {
std::thread::spawn(|| {
loop {
std::thread::sleep(std::time::Duration::from_secs(60));
if crate::remote::relay_addr().is_none() {
continue;
}
if crate::runfile::running("watch") {
continue;
}
let mut said = Vec::new();
let _ = crate::pull_for_mcp(&mut said);
}
});
}
pub fn serve(port: u16) -> std::io::Result<()> {
let listener = TcpListener::bind(("127.0.0.1", port))?;
println!("gage ui on http://127.0.0.1:{port} (ctrl-c to stop)");
background_pull();
for stream in listener.incoming() {
match stream {
Ok(s) => {
if let Err(e) = handle(s, port) {
eprintln!("gage ui: {e}");
}
}
Err(e) => eprintln!("gage ui: accept failed: {e}"),
}
}
Ok(())
}
fn respond(mut s: TcpStream, status: &str, ctype: &str, body: &str) -> std::io::Result<()> {
let head = format!(
"HTTP/1.1 {status}\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\n\
X-Content-Type-Options: nosniff\r\n\
Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; \
script-src 'unsafe-inline'; connect-src 'self'; img-src 'self'; \
form-action 'none'; base-uri 'none'\r\n\
Connection: close\r\n\r\n",
body.len()
);
s.write_all(head.as_bytes())?;
s.write_all(body.as_bytes())?;
s.flush()
}
fn bundle_id<'a>(target: &'a str, prefix: &str) -> Option<&'a str> {
let id = target.strip_prefix(prefix)?;
if id.is_empty() || id.len() > 64 || id.contains('/') {
return None;
}
if !id.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
Some(id)
}
fn handle(stream: TcpStream, port: u16) -> std::io::Result<()> {
let req = read_req(&stream)?;
let (method, target) = (req.method.as_str(), req.target.as_str());
if !host_is_local(&req, port) {
return respond(
stream,
"403 Forbidden",
"application/json",
"{\"error\":\"request not addressed to this loopback viewer\"}",
);
}
if method == "POST" && !may_mutate(&req, port) {
return respond(
stream,
"403 Forbidden",
"application/json",
"{\"error\":\"cross-site request refused\"}",
);
}
match (method, target) {
("GET", "/") => respond(stream, "200 OK", "text/html; charset=utf-8", INDEX),
("GET", "/favicon.svg") => respond(stream, "200 OK", "image/svg+xml", FAVICON),
("GET", "/api/state") => respond(stream, "200 OK", "application/json", &state_json()),
("GET", "/api/threads") => respond(stream, "200 OK", "application/json", &threads_json()),
("POST", t) if t.starts_with("/api/read/") => {
let Some(id) = bundle_id(t, "/api/read/") else {
return respond(
stream,
"400 Bad Request",
"application/json",
"{\"error\":\"bad id\"}",
);
};
let mut st = crate::state::LocalState::load();
match st.mark_read(id) {
Ok(()) => respond(stream, "200 OK", "application/json", "{\"ok\":true}"),
Err(e) => respond(
stream,
"500 Internal Server Error",
"application/json",
&serde_json::json!({"error": e.to_string()}).to_string(),
),
}
}
("POST", t) if t.starts_with("/api/close/") => {
let Some(id) = bundle_id(t, "/api/close/") else {
return respond(
stream,
"400 Bad Request",
"application/json",
"{\"error\":\"bad id\"}",
);
};
let mut st = crate::state::LocalState::load();
match st.close(id) {
Ok(()) => respond(stream, "200 OK", "application/json", "{\"ok\":true}"),
Err(e) => respond(
stream,
"500 Internal Server Error",
"application/json",
&serde_json::json!({"error": e.to_string()}).to_string(),
),
}
}
("POST", t) if t.starts_with("/api/accept/") || t.starts_with("/api/reject/") => {
let accept = t.starts_with("/api/accept/");
let Some(id) = bundle_id(
t,
if accept {
"/api/accept/"
} else {
"/api/reject/"
},
) else {
return respond(
stream,
"400 Bad Request",
"application/json",
"{\"error\":\"bad id\"}",
);
};
let me = crate::identity::home();
let from = crate::routes::pending_dir(&me).join(id);
if !from.is_dir() {
return respond(
stream,
"404 Not Found",
"application/json",
"{\"error\":\"no such pending bundle\"}",
);
}
let res = if accept {
let dest_dir = crate::routes::accepted_dir(&me);
std::fs::create_dir_all(&dest_dir)
.and_then(|_| crate::routes::restage(&from, &dest_dir.join(id)))
} else {
std::fs::remove_dir_all(&from)
};
match res {
Ok(()) => respond(stream, "200 OK", "application/json", "{\"ok\":true}"),
Err(e) => respond(
stream,
"500 Internal Server Error",
"application/json",
&serde_json::json!({ "error": e.to_string() }).to_string(),
),
}
}
("GET", "/api/nearby") => respond(stream, "200 OK", "application/json", &nearby_json()),
("POST", "/api/send") => match send_from_ui(&req.raw) {
Ok(lines) => respond(
stream,
"200 OK",
"application/json",
&serde_json::json!({ "ok": true, "lines": lines }).to_string(),
),
Err((code, msg)) => respond(
stream,
code,
"application/json",
&serde_json::json!({ "error": msg }).to_string(),
),
},
("GET", "/api/buddies") => respond(stream, "200 OK", "application/json", &buddies_json()),
("GET", "/api/agents") => respond(stream, "200 OK", "application/json", &agents_json()),
("POST", "/api/agent/remove") => match agent_remove_from_ui(&req.body) {
Ok(j) => respond(stream, "200 OK", "application/json", &j),
Err(e) => respond(
stream,
"400 Bad Request",
"application/json",
&serde_json::json!({ "error": e }).to_string(),
),
},
("POST", "/api/agent") => match agent_from_ui(&req.body) {
Ok(j) => respond(stream, "200 OK", "application/json", &j),
Err(e) => respond(
stream,
"400 Bad Request",
"application/json",
&serde_json::json!({ "error": e }).to_string(),
),
},
("POST", "/api/relay") => match relay_from_ui(&req.body) {
Ok(lines) => respond(
stream,
"200 OK",
"application/json",
&serde_json::json!({ "lines": lines }).to_string(),
),
Err(e) => respond(
stream,
"400 Bad Request",
"application/json",
&serde_json::json!({ "error": e }).to_string(),
),
},
("POST", "/api/parse-invite") => match parse_invite(&req.body) {
Ok(v) => respond(stream, "200 OK", "application/json", &v),
Err((code, msg)) => respond(
stream,
code,
"application/json",
&serde_json::json!({ "error": msg }).to_string(),
),
},
("GET", "/api/invite") => match invite_response() {
Ok(svg) => respond(stream, "200 OK", "image/svg+xml", &svg),
Err((code, body)) => respond(stream, code, "text/plain", body),
},
("POST", "/api/init") => match init_from_ui(&req.body) {
Ok(v) => respond(stream, "200 OK", "application/json", &v),
Err((code, msg)) => respond(
stream,
code,
"application/json",
&serde_json::json!({ "error": msg }).to_string(),
),
},
("POST", "/api/connect") => match connect_from_ui(&req.body) {
Ok(lines) => respond(
stream,
"200 OK",
"application/json",
&serde_json::json!({ "ok": true, "lines": lines }).to_string(),
),
Err((code, msg)) => respond(
stream,
code,
"application/json",
&serde_json::json!({ "error": msg }).to_string(),
),
},
("POST", "/api/decline") => match decline_from_ui(&req.body) {
Ok(msg) => respond(
stream,
"200 OK",
"application/json",
&serde_json::json!({ "ok": true, "lines": [msg] }).to_string(),
),
Err((code, msg)) => respond(
stream,
code,
"application/json",
&serde_json::json!({ "error": msg }).to_string(),
),
},
("POST", "/api/confirm") => match confirm_from_ui(&req.body) {
Ok(_) => respond(
stream,
"200 OK",
"application/json",
&serde_json::json!({ "ok": true }).to_string(),
),
Err((code, msg)) => respond(
stream,
code,
"application/json",
&serde_json::json!({ "error": msg }).to_string(),
),
},
("POST", "/api/pair") => match pair_from_ui(&req.body) {
Ok(lines) => respond(
stream,
"200 OK",
"application/json",
&serde_json::json!({ "ok": true, "lines": lines }).to_string(),
),
Err((code, msg)) => respond(
stream,
code,
"application/json",
&serde_json::json!({ "error": msg }).to_string(),
),
},
("POST", "/api/reply") => {
let out = reply_from_ui(&req.body);
match out {
Ok(id) => respond(
stream,
"200 OK",
"application/json",
&serde_json::json!({ "ok": true, "id": id }).to_string(),
),
Err((code, msg)) => respond(
stream,
code,
"application/json",
&serde_json::json!({ "error": msg }).to_string(),
),
}
}
_ => respond(stream, "404 Not Found", "text/plain", "not found\n"),
}
}
fn agents_json() -> String {
let agents: Vec<serde_json::Value> = crate::agentcfg::list()
.iter()
.map(|a| {
serde_json::json!({
"name": a.name,
"handle": a.handle,
"fingerprint": a.fingerprint(),
"home": a.home.to_string_lossy(),
"configs": crate::agentcfg::configs(a),
"grant": crate::agentcfg::grant_line(a),
})
})
.collect();
serde_json::json!({ "agents": agents }).to_string()
}
fn agent_from_ui(body: &str) -> Result<String, String> {
let v: serde_json::Value =
serde_json::from_str(body).map_err(|_| "could not read the request".to_string())?;
let name = v
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("")
.trim()
.to_string();
let agent = crate::agentcfg::create(&name)?;
Ok(serde_json::json!({
"ok": true,
"name": agent.name,
"handle": agent.handle,
"fingerprint": agent.fingerprint(),
"home": agent.home.to_string_lossy(),
"configs": crate::agentcfg::configs(&agent),
"grant": crate::agentcfg::grant_line(&agent),
})
.to_string())
}
fn agent_remove_from_ui(body: &str) -> Result<String, String> {
let v: serde_json::Value =
serde_json::from_str(body).map_err(|_| "could not read the request".to_string())?;
let name = v
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("")
.trim()
.to_string();
let r = crate::agentcfg::remove(&name)?;
Ok(serde_json::json!({
"ok": true,
"name": r.name,
"key_retired": r.key,
"route_removed": r.route_removed,
"home": r.home.to_string_lossy(),
})
.to_string())
}
fn relay_from_ui(body: &str) -> Result<Vec<String>, String> {
let v: serde_json::Value =
serde_json::from_str(body).map_err(|_| "could not read the request".to_string())?;
if v.get("off").and_then(|b| b.as_bool()) == Some(true) {
crate::remote::clear_relay().map_err(|e| e.to_string())?;
return Ok(vec![
"This machine no longer uses a relay.".into(),
"Anything already in your mailbox stays there until you turn this back on.".into(),
]);
}
let addr = v
.get("addr")
.and_then(|a| a.as_str())
.unwrap_or("")
.trim()
.to_string();
if addr.is_empty() {
return Err("No relay address given.".into());
}
if addr.len() > 200 {
return Err("That address is too long to be a relay address.".into());
}
if addr.chars().any(|c| c.is_control() || c.is_whitespace()) {
return Err("A relay address cannot contain spaces or control characters.".into());
}
let Some(host) = addr.strip_prefix("https://") else {
return Err(format!(
"The address must start with https:// -- {addr} would not verify who answered."
));
};
let host = host.trim_end_matches('/');
if host.is_empty() || host.starts_with(':') {
return Err("That address has no host in it.".into());
}
let id = crate::identity::Identity::load()
.map_err(|_| "This machine has no identity yet.".to_string())?;
let reg = serde_json::json!({
"sign_key": id.public_b64(), "box_key": id.box_public_b64() })
.to_string();
crate::register_with_proof(&addr, &id, ®).map_err(|e| e.to_string())?;
crate::remote::set_relay(&addr).map_err(|e| e.to_string())?;
Ok(vec![
format!("Registered with {addr}."),
"The relay holds sealed envelopes and cannot read them.".into(),
"Mail left for you arrives on its own while gage is running.".into(),
])
}
fn parse_invite(body: &str) -> Result<String, (&'static str, String)> {
let v: serde_json::Value =
serde_json::from_str(body).map_err(|e| ("400 Bad Request", format!("bad body: {e}")))?;
let text = v.get("text").and_then(|x| x.as_str()).unwrap_or("");
if text.len() > 4096 {
return Err(("400 Bad Request", "that is more text than an invite".into()));
}
let (name, token) = crate::quickstart::parse_add(text);
let Some(token) = token else {
return Err((
"400 Bad Request",
"no key in that text -- paste the whole `gage add` line".into(),
));
};
let sign = token.split('.').next().unwrap_or("").to_string();
if !crate::bundle::b64_decode_ok(&sign) {
return Err(("400 Bad Request", "that is not a key".into()));
}
let me = crate::identity::Identity::load().ok();
if let Some(id) = &me
&& id.public_b64() == sign
{
return Err((
"409 Conflict",
"that is this machine's own line -- you want the other person's".into(),
));
}
let contacts = crate::trust::Contacts::load();
if let Some(n) = contacts.keys.get(&sign) {
return Err((
"409 Conflict",
format!("that key is already on your list as {n}"),
));
}
Ok(serde_json::json!({
"key": sign,
"suggested_name": name.unwrap_or_default(),
"ask_pair": asked_pair(&sign) + 1,
})
.to_string())
}
fn invite_response() -> Result<String, (&'static str, &'static str)> {
let id = match crate::identity::Identity::load() {
Ok(id) => id,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(("404 Not Found", "not found\n"));
}
Err(_) => return Err(("500 Internal Server Error", "identity could not be read\n")),
};
crate::invite::svg(&id.add_line()).map_err(|_| {
(
"500 Internal Server Error",
"the add line does not fit in a QR code\n",
)
})
}
fn nearby_json() -> String {
let c = crate::trust::Contacts::load();
let now = crate::lan::now();
let list: Vec<serde_json::Value> = crate::lan::read_nearby()
.into_iter()
.filter(|e| now.saturating_sub(e.at) <= 60)
.map(|e| {
serde_json::json!({
"label": e.label,
"handle": e.handle,
"addr": e.addr,
"fingerprint": crate::words::fingerprint_of(&e.sign, Some(&e.boxpub)),
"sign": e.sign,
"paired_as": c.name_for(&e.sign),
"age": now.saturating_sub(e.at),
})
})
.collect();
serde_json::json!({
"lan_running": !list.is_empty() || crate::lan::nearby_path().exists(),
"devices": list,
})
.to_string()
}
fn first_observed(key: &str) -> Option<String> {
crate::journal::read()
.into_iter()
.filter(|e| e.event == crate::journal::Event::Observed && e.from_key == key)
.map(|e| e.at)
.min()
}
pub fn buddies_json() -> String {
let observed = observed();
let home = crate::identity::home();
let contacts = crate::trust::Contacts::load();
let routes = crate::routes::Routes::load();
let st = crate::state::LocalState::load();
let relay = crate::remote::relay_addr();
let now = crate::lan::now();
let near: std::collections::BTreeSet<String> = crate::lan::read_nearby()
.into_iter()
.filter(|e| now.saturating_sub(e.at) <= 60)
.map(|e| e.sign)
.collect();
let mut unread: std::collections::BTreeMap<String, u64> = Default::default();
let mut waiting: std::collections::BTreeMap<String, u64> = Default::default();
let mut askers: std::collections::BTreeMap<String, u64> = Default::default();
for (stage, dir) in [
("pending", crate::routes::pending_dir(&home)),
("accepted", crate::routes::accepted_dir(&home)),
] {
for (_, l) in crate::routes::list(&dir) {
let m = &l.manifest;
let k = m.from.key.clone();
if stage == "pending" {
*waiting.entry(k.clone()).or_default() += 1;
}
if !st.is_read(&m.id) {
*unread.entry(k.clone()).or_default() += 1;
}
if matches!(l.trust, crate::trust::Trust::Unverified)
|| !l.tampered.is_empty()
|| !is_a_key(&k)
{
continue;
}
if !contacts.knows(&k)
&& !contacts.declined.contains_key(&k)
&& !contacts.retired.contains_key(&k)
{
*askers.entry(k).or_insert(0) += 1;
}
}
}
let reach = |key: &str, name: &str| -> serde_json::Value {
serde_json::json!({
"routed_by_name": routes.local.contains_key(name),
"lan_now": near.contains(key),
"can_leave_mail": relay.is_some() && contacts.box_keys.contains_key(key),
})
};
let agent_keys: std::collections::BTreeMap<String, String> = crate::agentcfg::list()
.iter()
.map(|a| (crate::identity::split_key(&a.key).0, a.name.clone()))
.collect();
let buddies: Vec<serde_json::Value> = contacts
.keys
.iter()
.filter(|(k, _)| !agent_keys.contains_key(*k))
.map(|(k, n)| {
serde_json::json!({
"name": n,
"key": k,
"fingerprint": contacts.fingerprint_for(k),
"label": crate::words::label(k),
"sealable": contacts.box_keys.contains_key(k),
"confirmed": contacts.is_confirmed(k),
"stale": contacts.stale_confirmation(k).is_some(),
"unread": unread.get(k).copied().unwrap_or(0),
"waiting": waiting.get(k).copied().unwrap_or(0),
"reach": reach(k, n),
})
})
.collect();
let requests: Vec<serde_json::Value> = askers
.iter()
.map(|(k, count)| {
serde_json::json!({
"key": k,
"label": crate::words::label(k),
"ask_pair": asked_pair(k) + 1,
"first_observed": first_observed(k),
"bundles": count,
"verdict": verdict_label_for(observed, contacts.verdict(k, None)),
})
})
.collect();
let refused: Vec<serde_json::Value> = contacts
.declined
.iter()
.filter(|(k, _)| is_a_key(k))
.map(|(k, d)| {
serde_json::json!({
"key": k,
"label": crate::words::label(k),
"at": d.at,
"holding": waiting.get(k).copied().unwrap_or(0)
+ unread.get(k).copied().unwrap_or(0),
})
})
.collect();
let agents_on_list: Vec<serde_json::Value> = agent_keys
.iter()
.filter(|(k, _)| contacts.keys.contains_key(*k))
.map(|(k, name)| {
serde_json::json!({
"name": name,
"unread": unread.get(k).copied().unwrap_or(0),
})
})
.collect();
serde_json::json!({
"verdicts_stale": !observed,
"relay": relay,
"buddies": buddies,
"agents_on_list": agents_on_list,
"requests": requests,
"refused": refused,
})
.to_string()
}
fn safe_upload_name(raw: &str) -> Option<String> {
let base = raw.rsplit(['/', '\\']).next().unwrap_or("");
if base.is_empty() || base == "." || base == ".." || base.len() > 120 {
return None;
}
if !base
.chars()
.all(|c| c.is_ascii_alphanumeric() || "._- ()[]".contains(c))
{
return None;
}
if base.starts_with('.') {
return None;
}
Some(base.to_string())
}
fn send_from_ui(raw: &[u8]) -> Result<Vec<String>, (&'static str, String)> {
use base64::{Engine, engine::general_purpose::STANDARD as B64};
let v: serde_json::Value =
serde_json::from_slice(raw).map_err(|e| ("400 Bad Request", format!("bad body: {e}")))?;
let to = v
.get("to")
.and_then(|x| x.as_str())
.unwrap_or("")
.trim()
.to_string();
if to.is_empty() {
return Err(("400 Bad Request", "no device chosen".into()));
}
let subject = v
.get("subject")
.and_then(|x| x.as_str())
.unwrap_or("")
.trim()
.to_string();
let subject = if subject.is_empty() {
"(no subject)".to_string()
} else {
subject
};
let msg_type = v
.get("type")
.and_then(|x| x.as_str())
.unwrap_or("share")
.to_string();
let files = v
.get("files")
.and_then(|x| x.as_array())
.cloned()
.unwrap_or_default();
let text = v
.get("text")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string();
if text.len() > 64 * 1024 {
return Err(("400 Bad Request", "that note is larger than a note".into()));
}
if files.is_empty() && text.trim().is_empty() {
return Err((
"400 Bad Request",
"nothing to send -- drop a file, write a note, or both".into(),
));
}
let tmp = crate::identity::home().join(format!("tmp-ui-send-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).map_err(|e| ("500 Internal Server Error", e.to_string()))?;
let run = |tmp: &std::path::Path| -> Result<Vec<String>, (&'static str, String)> {
let mut paths = Vec::new();
let mut seen: Vec<String> = Vec::new();
if !text.trim().is_empty() {
let mut body = text.clone();
if !body.ends_with('\n') {
body.push('\n');
}
let f = tmp.join("message.md");
std::fs::write(&f, body).map_err(|e| ("500 Internal Server Error", e.to_string()))?;
seen.push("message.md".to_string());
paths.push(f);
}
for f in &files {
let name = f.get("name").and_then(|x| x.as_str()).unwrap_or("");
let Some(name) = safe_upload_name(name) else {
return Err((
"400 Bad Request",
format!("{name:?} is not a name this will write to disk"),
));
};
if seen.contains(&name) {
return Err((
"400 Bad Request",
format!("two files called {name} -- one would have overwritten the other"),
));
}
seen.push(name.clone());
let b64 = f.get("data").and_then(|x| x.as_str()).unwrap_or("");
let bytes = B64.decode(b64).map_err(|e| {
(
"400 Bad Request",
format!("{name}: unreadable contents: {e}"),
)
})?;
let p = tmp.join(&name);
std::fs::write(&p, &bytes).map_err(|e| ("500 Internal Server Error", e.to_string()))?;
paths.push(p);
}
let mut say = Vec::new();
let m = crate::outbound::send_bundle(
crate::outbound::SendArgs {
to: vec![to.clone()],
subject: subject.clone(),
out: None,
file: paths,
prompt: vec![],
decision: vec![],
memory: vec![],
dir: vec![],
allow_secrets: false,
reply_to: None,
msg_type: msg_type.clone(),
allow_findings: false,
lan: true,
agent: None,
model: None,
record_send: true,
ttl: None,
expires: None,
context: None,
},
&mut say,
);
for l in &say {
println!("{l}");
}
let m = m.map_err(|e| ("400 Bad Request", e.to_string()))?;
let from = crate::routes::sent_dir(&crate::identity::home()).join(&m.id);
let mut out = vec![format!("bundle {} -- {} item(s)", m.id, m.items.len())];
out.extend(crate::lan::hand_over(&from, &to).map_err(|e| ("502 Bad Gateway", e))?);
Ok(out)
};
let r = run(&tmp);
let _ = std::fs::remove_dir_all(&tmp);
r
}
fn pair_from_ui(body: &str) -> Result<Vec<String>, (&'static str, String)> {
let v: serde_json::Value =
serde_json::from_str(body).map_err(|e| ("400 Bad Request", format!("bad body: {e}")))?;
let label = v
.get("label")
.and_then(|x| x.as_str())
.unwrap_or("")
.trim()
.to_string();
let name = v
.get("name")
.and_then(|x| x.as_str())
.unwrap_or("")
.trim()
.to_string();
if name.is_empty() {
return Err(("400 Bad Request", "no name to file it under".into()));
}
if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == ' ')
|| name.len() > 40
{
return Err((
"400 Bad Request",
"a contact name is letters, digits, spaces, - and _".into(),
));
}
let e = crate::lan::lookup_nearby(&label).ok_or((
"404 Not Found",
format!("{label} is not on this network now"),
))?;
if crate::lan::now().saturating_sub(e.at) > 60 {
return Err((
"409 Conflict",
"that entry is stale -- is gage lan still running?".into(),
));
}
let shown = v.get("fingerprint").and_then(|x| x.as_str()).unwrap_or("");
let real = crate::words::fingerprint_of(&e.sign, Some(&e.boxpub));
if shown != real {
return Err((
"409 Conflict",
format!("the words on screen are not this key's any more ({real}) -- look again"),
));
}
crate::trust::add_contact(
&name,
&{
if e.boxpub.is_empty() {
e.sign.clone()
} else {
format!("{}.{}", e.sign, e.boxpub)
}
},
crate::trust::AddOpts::plain(),
)
.map_err(|e| ("400 Bad Request", e))
}
fn confirm_from_ui(body: &str) -> Result<String, (&'static str, String)> {
let v: serde_json::Value =
serde_json::from_str(body).map_err(|e| ("400 Bad Request", format!("bad body: {e}")))?;
let name = v
.get("name")
.and_then(|x| x.as_str())
.unwrap_or("")
.trim()
.to_string();
let mut c = crate::trust::Contacts::load();
let key = c
.key_for_name(&name)
.ok_or_else(|| ("404 Not Found", format!("no contact named {name}")))?;
let seq = crate::state::LocalState::load().seq;
let typed = v.get("words").and_then(|x| x.as_str()).unwrap_or("");
if !typed.is_empty() {
c.confirm(&key, typed, "word-pairs typed back in the viewer", seq)
.map_err(|e| ("409 Conflict", e))?;
} else {
let shown = v.get("fingerprint").and_then(|x| x.as_str()).unwrap_or("");
let real = c.fingerprint_for(&key);
if shown != real {
return Err((
"409 Conflict",
format!("the words on screen are not this key's any more ({real}) -- look again"),
));
}
c.confirm(
&key,
&real,
"word-pairs compared in the viewer -- shown on both screens, not typed",
seq,
)
.map_err(|e| ("409 Conflict", e))?;
}
c.save()
.map_err(|e| ("500 Internal Server Error", e.to_string()))?;
Ok(serde_json::json!({"confirmed": name}).to_string())
}
pub fn is_a_key(s: &str) -> bool {
(43..=64).contains(&s.len())
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=')
&& crate::bundle::b64_decode_ok(s)
}
pub fn asked_pair(sign: &str) -> usize {
let h = crate::bundle::sha256_hex(format!("which-pair:{sign}").as_bytes());
1 + usize::from_str_radix(&h[..2], 16).unwrap_or(0) % 3
}
const BACKUP_WARNING: &str = "Your key is the only copy, and it lives on this machine. \
If it is lost, this identity is gone and there is no way back in -- every \
contact would have to confirm a new key with you out loud. In a terminal, \
`gage export-identity` writes a passphrase-encrypted backup of it. Opt-in, \
and a real trade: it turns \"lose the key, lose everything\" into \"your \
security is your passphrase's strength\". Keep the file somewhere other \
than the machine it came from.";
fn handle_ok(h: &str) -> bool {
h.is_empty()
|| (h
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == ' ')
&& h.len() <= 40)
}
fn init_from_ui(body: &str) -> Result<String, (&'static str, String)> {
let v: serde_json::Value =
serde_json::from_str(body).map_err(|e| ("400 Bad Request", format!("bad body: {e}")))?;
let handle = v
.get("handle")
.and_then(|x| x.as_str())
.unwrap_or("")
.trim()
.to_string();
if !handle_ok(&handle) {
return Err((
"400 Bad Request",
"a handle is letters, digits, spaces, - and _".into(),
));
}
let chosen = if handle.is_empty() {
None
} else {
Some(handle.as_str())
};
match crate::identity::Identity::create_named(chosen) {
Ok(id) => Ok(serde_json::json!({
"ok": true,
"handle": id.handle,
"words": crate::words::fingerprint_of(&id.public_b64(), Some(&id.box_public_b64())),
"backup_warning": BACKUP_WARNING,
})
.to_string()),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
Err(("409 Conflict", e.to_string()))
}
Err(e) => Err(("500 Internal Server Error", e.to_string())),
}
}
fn connect_from_ui(body: &str) -> Result<Vec<String>, (&'static str, String)> {
let v: serde_json::Value =
serde_json::from_str(body).map_err(|e| ("400 Bad Request", format!("bad body: {e}")))?;
let key = v
.get("key")
.and_then(|x| x.as_str())
.unwrap_or("")
.trim()
.to_string();
let name = v
.get("name")
.and_then(|x| x.as_str())
.unwrap_or("")
.trim()
.to_string();
let typed = v
.get("confirm")
.and_then(|x| x.as_str())
.unwrap_or("")
.trim()
.to_lowercase();
if !crate::bundle::b64_decode_ok(&key) {
return Err(("400 Bad Request", "that is not a key".into()));
}
let reveal_only = name.is_empty();
if !reveal_only
&& (!name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == ' ')
|| name.len() > 40)
{
return Err((
"400 Bad Request",
"a contact name is letters, digits, spaces, - and _".into(),
));
}
let contacts = crate::trust::Contacts::load();
if let Some(n) = contacts.keys.get(&key) {
return Err((
"409 Conflict",
format!("that key is already on your list as {n}"),
));
}
let pairs = crate::words::pairs(&key, 4);
let want = &pairs[asked_pair(&key)];
if typed != *want {
return Err((
"409 Conflict",
format!(
"that is not pair {} of this key's fingerprint. Read it off the OTHER \
machine -- it is deliberately not shown on this one, so that typing \
it means something.",
asked_pair(&key) + 1
),
));
}
if reveal_only {
return Ok(vec![
"reveal".to_string(),
crate::words::fingerprint_of(&key, contacts.box_keys.get(&key).map(|s| s.as_str())),
]);
}
let token = match contacts.box_keys.get(&key) {
Some(b) => format!("{key}.{b}"),
None => key.clone(),
};
let mut out = crate::trust::add_contact(&name, &token, crate::trust::AddOpts::plain())
.map_err(|e| ("400 Bad Request", e))?;
if !contacts.box_keys.contains_key(&key) {
out.push(
"to seal mail to them as well, get their `gage whoami` token \
(signpub.boxpub) and add it over this entry, or pair on the network."
.to_string(),
);
}
Ok(out)
}
fn decline_from_ui(body: &str) -> Result<String, (&'static str, String)> {
let v: serde_json::Value =
serde_json::from_str(body).map_err(|e| ("400 Bad Request", format!("bad body: {e}")))?;
let key = v
.get("key")
.and_then(|x| x.as_str())
.unwrap_or("")
.trim()
.to_string();
let undo = v.get("undo").and_then(|x| x.as_bool()).unwrap_or(false);
if !crate::bundle::b64_decode_ok(&key) {
return Err(("400 Bad Request", "that is not a key".into()));
}
let mut c = crate::trust::Contacts::load();
if undo {
return match c.declined.remove(&key) {
Some(_) => {
c.save()
.map_err(|e| ("500 Internal Server Error", e.to_string()))?;
Ok("no longer refused -- and still not on your list".to_string())
}
None => Err(("409 Conflict", "that key is not refused".into())),
};
}
let seq = crate::state::LocalState::load().seq;
match c.decline(&key, seq).map_err(|e| ("409 Conflict", e))? {
true => {
c.save()
.map_err(|e| ("500 Internal Server Error", e.to_string()))?;
Ok("refused. Its bundles still arrive; you are not asked again".to_string())
}
false => Ok("already refused".to_string()),
}
}
fn reply_from_ui(body: &str) -> Result<String, (&'static str, String)> {
let v: serde_json::Value = serde_json::from_str(body)
.map_err(|e| ("400 Bad Request", format!("unreadable request: {e}")))?;
let thread = v
.get("thread")
.and_then(|t| t.as_str())
.unwrap_or("")
.trim()
.to_string();
let text = v.get("text").and_then(|t| t.as_str()).unwrap_or("");
let msg_type = v
.get("type")
.and_then(|t| t.as_str())
.map(|s| s.to_string());
if thread.is_empty() || !thread.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(("400 Bad Request", "bad thread id".into()));
}
if text.trim().is_empty() {
return Err(("400 Bad Request", "nothing to send".into()));
}
let t = crate::outbound::resolve_reply(&thread).map_err(|e| ("409 Conflict", e.to_string()))?;
if crate::routes::Routes::load().home_of(&t.to).is_none() {
return Err((
"409 Conflict",
format!(
"{} has no local route, so this reply cannot be delivered from here. \
In a terminal: gage reply {} --text \"...\" then gage push <dir> --to {} --yes",
t.to, thread, t.to
),
));
}
let tmp = crate::identity::home().join(format!("tmp-ui-{}", std::process::id()));
std::fs::create_dir_all(&tmp).map_err(|e| ("500 Internal Server Error", e.to_string()))?;
let f = tmp.join("reply.md");
let mut body = text.to_string();
if !body.ends_with('\n') {
body.push('\n');
}
std::fs::write(&f, body).map_err(|e| ("500 Internal Server Error", e.to_string()))?;
let mut say = Vec::new();
let r = crate::outbound::send_bundle(
crate::outbound::SendArgs {
to: vec![t.to.clone()],
subject: t.subject,
out: None,
file: vec![],
prompt: vec![],
decision: vec![f],
memory: vec![],
dir: vec![],
allow_secrets: false,
reply_to: Some(t.parent),
msg_type: msg_type.unwrap_or(t.msg_type),
allow_findings: false,
lan: false,
agent: None,
model: None,
record_send: true,
ttl: None,
expires: None,
context: None,
},
&mut say,
);
let _ = std::fs::remove_dir_all(&tmp);
for l in &say {
println!("{l}");
}
match r {
Ok(m) => Ok(m.id),
Err(e) => Err(("409 Conflict", e.to_string())),
}
}
#[must_use]
fn observed() -> bool {
match crate::routes::observe_inbox() {
Ok(_) => true,
Err(e) => {
eprintln!("gage ui: WARNING -- inbox not recorded ({e}); verdicts withheld");
false
}
}
}
fn verdict_label(observed: bool, l: &crate::bundle::Loaded) -> &'static str {
verdict_label_for(observed, l.trust)
}
fn verdict_label_for(observed: bool, t: crate::trust::Trust) -> &'static str {
if observed {
t.label()
} else {
crate::trust::VERDICT_UNAVAILABLE
}
}
fn state_json() -> String {
let observed = observed();
let me = crate::identity::home();
let contacts = crate::trust::Contacts::load();
let routes = crate::routes::Routes::load();
let id = crate::identity::Identity::load().ok();
let stage = |dir: std::path::PathBuf, name: &str| -> Vec<serde_json::Value> {
crate::routes::list(&dir)
.into_iter()
.map(|(_, l)| {
let m = &l.manifest;
serde_json::json!({
"stage": name,
"id": m.id,
"subject": m.subject,
"created": m.created,
"claimed_name": m.from.handle,
"known_as": contacts.name_any(&m.from.key),
"key": m.from.key,
"trust": verdict_label(observed, &l),
"forwarded": m.is_forwarded(),
"hops": m.chain.len(),
"prompts": m.prompt_count(),
"memories": m.memory_count(),
"bytes": m.total_bytes(),
"in_reply_to": m.in_reply_to,
"items": m.items.iter().map(|i| serde_json::json!({
"kind": format!("{:?}", i.kind).to_lowercase(),
"path": i.path.strip_prefix("items/").unwrap_or(&i.path),
"bytes": i.bytes,
})).collect::<Vec<_>>(),
"tampered": l.tampered,
"freshness": l.freshness.word(),
"expires": l.freshness.declared(),
"context": m.context,
"context_match": l.context_match.word(),
"agent": m.agent.as_ref().map(|a| serde_json::json!({
"name": a.name, "model": a.model })),
})
})
.collect()
};
serde_json::json!({
"has_identity": me.join("identity.key").exists(),
"relay": crate::remote::relay_addr(),
"relay_default": RELAY_DEFAULT,
"me": id.map(|i| serde_json::json!({ "handle": i.handle, "key": i.public_b64() })),
"verdicts_stale": !observed,
"home": me.display().to_string(),
"pending": stage(crate::routes::pending_dir(&me), "pending"),
"accepted": stage(crate::routes::accepted_dir(&me), "accepted"),
"contacts": contacts.keys.iter().map(|(k, n)| serde_json::json!({
"name": n, "key": k, "routed": routes.local.contains_key(n) })).collect::<Vec<_>>(),
"groups": contacts.groups.iter().map(|(g, m)| serde_json::json!({
"name": g, "members": m.len() })).collect::<Vec<_>>(),
"routes": routes.local,
"ever_sent": crate::journal::has_sent()
|| std::fs::read_dir(crate::routes::sent_dir(&me))
.map(|mut d| d.next().is_some())
.unwrap_or(false),
})
.to_string()
}
fn threads_json() -> String {
let observed = observed();
let me_id = crate::identity::Identity::load().ok();
let mine = me_id.as_ref().map(|i| i.public_b64()).unwrap_or_default();
let st = crate::state::LocalState::load();
let contacts = crate::trust::Contacts::load();
let routes = crate::routes::Routes::load();
let home = crate::identity::home();
let mut stage_of: std::collections::BTreeMap<String, (&str, String)> = Default::default();
let mut trust_of: std::collections::BTreeMap<String, (String, Vec<String>)> =
Default::default();
let mut manifests = Vec::new();
for (name, dir) in [
("pending", crate::routes::pending_dir(&home)),
("accepted", crate::routes::accepted_dir(&home)),
("sent", crate::routes::sent_dir(&home)),
] {
for (_, l) in crate::routes::list(&dir) {
let id = l.manifest.id.clone();
stage_of
.entry(id.clone())
.or_insert((name, dir.join(&id).display().to_string()));
trust_of
.entry(id.clone())
.or_insert((verdict_label(observed, &l).to_string(), l.tampered.clone()));
if !manifests
.iter()
.any(|m: &crate::manifest::Manifest| m.id == id)
{
manifests.push(l.manifest);
}
}
}
let threads = crate::state::threads(manifests, &st, &mine);
let now = crate::bundle::now_secs();
let local_ctx = crate::identity::run_context();
let rows: Vec<serde_json::Value> = threads
.iter()
.map(|t| {
let msgs: Vec<serde_json::Value> = t
.messages
.iter()
.map(|m| {
let is_mine = m.from.key == mine;
let (stage, path) = stage_of
.get(&m.id)
.cloned()
.unwrap_or(("unknown", String::new()));
let (trust, tampered) = trust_of.get(&m.id).cloned().unwrap_or_default();
serde_json::json!({
"id": m.id,
"mine": is_mine,
"key": m.from.key,
"type": m.msg_type.label(),
"expects_reply": m.msg_type.expects_reply(),
"created": m.created,
"subject": m.subject,
"stage": stage,
"path": path,
"read": is_mine || st.is_read(&m.id),
"trust": trust,
"tampered": tampered,
"forwarded": m.is_forwarded(),
"thread_ok": !t.unverified.contains(&m.id),
"hops": m.chain.len(),
"claimed_name": m.from.handle,
"known_as": contacts.name_any(&m.from.key),
"prompts": m.prompt_count(),
"memories": m.memory_count(),
"bytes": m.total_bytes(),
"freshness": m.freshness(now).word(),
"expires": m.expires,
"context": m.context,
"context_match": crate::manifest::context_match(
m.context.as_deref(), local_ctx.as_deref()).word(),
"items": m.items.iter().map(|i| serde_json::json!({
"kind": format!("{:?}", i.kind).to_lowercase(),
"path": i.path.strip_prefix("items/").unwrap_or(&i.path),
"bytes": i.bytes,
})).collect::<Vec<_>>(),
})
})
.collect();
let parent = t.messages.iter().rev().find(|m| m.from.key != mine);
let reply = if t.others.len() > 1 {
serde_json::json!({ "can": false,
"why": format!("this thread has {} correspondents, so replying to \
the thread does not say who to. Reply to one \
message from a terminal: gage reply <message id>",
t.others.len()) })
} else {
match parent {
None => serde_json::json!({ "can": false,
"why": "every message here is your own" }),
Some(p) => match contacts.name_for(&p.from.key) {
None => serde_json::json!({ "can": false,
"why": "the key that signed the last message is not on your list" }),
Some(name) if routes.home_of(name).is_none() => serde_json::json!({
"can": false, "to": name,
"why": format!("{name} has no local route, so a reply has to go out \
through gage push in a terminal") }),
Some(_) if t.unverified.contains(&p.id) => serde_json::json!({
"can": false,
"why": "the newest message claims this thread while answering \
something you do not hold, so it is not a safe reply \
target" }),
Some(name) => serde_json::json!({ "can": true, "to": name,
"type": if p.msg_type.expects_reply() { "answer" } else { "share" } }),
},
}
};
serde_json::json!({
"id": t.id,
"subject": t.subject,
"state": t.state.label(),
"unread": t.unread,
"correspondents": t.others.len(),
"reply": reply,
"messages": msgs,
})
})
.collect();
serde_json::json!({
"me": me_id.map(|i| serde_json::json!({ "handle": i.handle, "key": i.public_b64() })),
"verdicts_stale": !observed,
"threads": rows,
"contacts": contacts.keys.iter().map(|(k, n)| serde_json::json!({
"name": n, "key": k })).collect::<Vec<_>>(),
})
.to_string()
}
const INDEX: &str = include_str!("ui.html");
const FAVICON: &str = include_str!("../packaging/icon/gage-icon.svg");
const RELAY_DEFAULT: &str = "https://relay.gage.sh";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_pages_verdict_table_matches_the_verdicts_that_exist() {
let keys: Vec<String> = INDEX
.lines()
.skip_while(|l| !l.contains("const V=["))
.take_while(|l| !l.contains("const vRow"))
.flat_map(|l| {
l.match_indices('[')
.filter_map(|(i, _)| l[i + 1..].strip_prefix('\''))
.filter_map(|rest| rest.split_once('\'').map(|(k, _)| k.to_string()))
.collect::<Vec<_>>()
})
.collect();
assert!(
keys.len() >= 7,
"could not parse the page's verdict table: {keys:?}"
);
let all: Vec<&'static str> = crate::trust::ALL
.iter()
.map(|t| t.label())
.chain([crate::trust::VERDICT_UNAVAILABLE])
.collect();
for label in &all {
let hits: Vec<&String> = keys
.iter()
.filter(|k| label.starts_with(k.as_str()))
.collect();
assert_eq!(
hits.len(),
1,
"{label:?} matches {} rows: {hits:?}",
hits.len()
);
}
for k in &keys {
assert!(
all.iter().any(|l| l.starts_with(k.as_str())),
"the page has a row for {k:?}, which no verdict starts with"
);
}
}
#[test]
fn a_key_the_page_will_render_has_to_be_shaped_like_one() {
for good in [
"XUkMcjZCz3cGTtxbGGzItqbc1Cwjr3KZyZ4BFzeUeSU=",
"bpGwtgSzi/6+0rqP4gPiwixfBfHPkPwP6RBErpaQn5o=",
] {
assert!(is_a_key(good), "rejected a real key: {good}");
}
for bad in [
"",
"short",
"XUkMcjZCz3cGTtxbGGzItqbc1Cwjr3KZyZ4BFzeU'SU=",
"XUkMcjZCz3cGTtxbGGzItqbc1Cwjr3KZyZ4BFzeU<SU=",
&"A".repeat(4096),
] {
assert!(
!is_a_key(bad),
"accepted something that is not a key: {bad:?}"
);
}
}
#[test]
fn the_add_call_resends_the_pair_the_reveal_confirmed() {
let body_of = |name: &str| -> String {
let after = INDEX
.split(&format!("function {name}"))
.nth(1)
.unwrap_or_else(|| panic!("{name} exists"));
let end = after.find("\n}").unwrap_or(after.len());
after[..end].to_string()
};
let reveal = body_of("budReveal");
assert!(
reveal.contains("pair:ACONF") || reveal.contains("pair: ACONF"),
"budReveal does not keep the confirmed pair: {reveal}"
);
let connect = body_of("budConnect");
assert!(
connect.contains("REVEAL.pair"),
"budConnect does not resend the confirmed pair: {connect}"
);
}
#[test]
fn the_asked_pair_is_never_the_one_the_label_gives_away() {
for i in 0..400u32 {
let k = crate::bundle::sha256_hex(&i.to_le_bytes());
let a = asked_pair(&k);
assert!((1..4).contains(&a), "asked pair {a} for {k}");
let pairs = crate::words::pairs(&k, 4);
assert_ne!(
pairs[a], pairs[0],
"the asked pair equals the label for {k}"
);
}
}
#[test]
fn the_relay_switch_refuses_an_address_it_cannot_verify() {
for bad in [
"relay.gage.sh",
"http://relay.gage.sh",
"HTTPS://relay.gage.sh",
"ftp://relay.gage.sh",
"//relay.gage.sh",
"https://",
"https://:443",
"",
" ",
] {
let body = serde_json::json!({ "addr": bad }).to_string();
assert!(
relay_from_ui(&body).is_err(),
"accepted an unverifiable relay address: {bad:?}"
);
}
}
#[test]
fn the_relay_switch_refuses_an_address_with_room_to_hide_something() {
for bad in [
"https://relay.gage.sh evil",
"https://relay.gage.sh\nHost: evil",
"https://relay.gage.sh\r\nHost: evil",
"https://relay.gage.sh\tx",
"https://relay\u{0000}.gage.sh",
] {
let body = serde_json::json!({ "addr": bad }).to_string();
assert!(
relay_from_ui(&body).is_err(),
"accepted a relay address containing whitespace or control bytes: {bad:?}"
);
}
let long = format!("https://{}.example.com", "a".repeat(250));
let body = serde_json::json!({ "addr": long }).to_string();
assert!(
relay_from_ui(&body).is_err(),
"accepted an over-long address"
);
}
#[test]
fn an_id_route_matches_exactly_one_segment() {
assert_eq!(bundle_id("/api/read/abc123", "/api/read/"), Some("abc123"));
assert_eq!(bundle_id("/api/accept/extra/abc123", "/api/accept/"), None);
assert_eq!(bundle_id("/api/read//abc123", "/api/read/"), None);
assert_eq!(bundle_id("/api/read/abc123/", "/api/read/"), None);
assert_eq!(bundle_id("/api/read/", "/api/read/"), None);
assert_eq!(bundle_id("/api/read/../../etc", "/api/read/"), None);
assert_eq!(bundle_id("/api/read/zz", "/api/read/"), None, "hex only");
assert_eq!(bundle_id("/api/read/abc?x=1", "/api/read/"), None);
assert_eq!(
bundle_id(&format!("/api/read/{}", "a".repeat(65)), "/api/read/"),
None
);
assert_eq!(bundle_id("/api/close/abc", "/api/read/"), None);
}
#[test]
fn an_uploaded_name_cannot_become_a_path() {
for (raw, want) in [
("../../../../tmp/gage-ui-pwn", "gage-ui-pwn"),
("docs/notes.md", "notes.md"),
("C:\\Users\\x\\secrets.md", "secrets.md"),
("notes.md", "notes.md"),
("a b (1).md", "a b (1).md"),
] {
assert_eq!(
safe_upload_name(raw).as_deref(),
Some(want),
"{raw:?} should land as {want:?}"
);
}
for bad in [
"..",
".",
"",
".env",
".ssh",
"a\u{0000}b",
"nul\nline.md",
"semi;colon.md",
"quote\".md",
"star*.md",
"tilde~.md",
"$HOME.md",
] {
assert_eq!(
safe_upload_name(bad),
None,
"{bad:?} should not become a filename"
);
}
assert_eq!(safe_upload_name(&"a".repeat(200)), None);
}
#[test]
fn a_failed_observation_withholds_the_verdict_instead_of_softening_it() {
assert_eq!(
verdict_label_for(false, crate::trust::Trust::Revoked),
crate::trust::VERDICT_UNAVAILABLE
);
assert_eq!(
verdict_label_for(true, crate::trust::Trust::Revoked),
crate::trust::Trust::Revoked.label()
);
assert_ne!(
crate::trust::VERDICT_UNAVAILABLE,
crate::trust::Trust::Known.label()
);
assert_ne!(
crate::trust::VERDICT_UNAVAILABLE,
crate::trust::Trust::Unwitnessed.label()
);
}
#[test]
fn a_first_run_handle_is_plain_or_derived() {
assert!(handle_ok(""));
for good in ["alice", "alice ware", "a-1_b", "crisp beetle"] {
assert!(handle_ok(good), "refused a plain handle: {good:?}");
}
for bad in [
"../etc",
"a/b",
"a\\b",
"line\nbreak",
"tab\there",
"nul\u{0}byte",
"o'brien",
"emoji\u{1f600}",
&"a".repeat(41),
] {
assert!(!handle_ok(bad), "accepted a hostile handle: {bad:?}");
}
}
#[test]
fn a_nearby_send_needs_a_file_or_a_note() {
let e = send_from_ui(br#"{"to":"cedar cinder","files":[]}"#)
.expect_err("accepted a send with nothing in it");
assert_eq!(e.0, "400 Bad Request");
assert!(e.1.contains("drop a file, write a note"), "{}", e.1);
let e = send_from_ui(br#"{"to":"cedar cinder","files":[],"text":" \n "}"#)
.expect_err("accepted whitespace as a note");
assert_eq!(e.0, "400 Bad Request");
let e = send_from_ui(br#"{"to":"","text":"hello"}"#)
.expect_err("accepted a send with no device");
assert!(e.1.contains("no device chosen"), "{}", e.1);
let big = serde_json::json!({
"to": "cedar cinder",
"files": [],
"text": "x".repeat(64 * 1024 + 1),
})
.to_string();
let e = send_from_ui(big.as_bytes()).expect_err("accepted an oversized note");
assert!(e.1.contains("larger than a note"), "{}", e.1);
let clash = serde_json::json!({
"to": "cedar cinder",
"text": "the note",
"files": [{ "name": "message.md", "data": "aGk=" }],
})
.to_string();
match send_from_ui(clash.as_bytes()) {
Err((_, msg)) => assert!(
msg.contains("two files called message.md") || !msg.contains("overwrit"),
"the collision was not reported as one: {msg}"
),
Ok(_) => panic!("a file named message.md was silently merged with the note"),
}
let ok_shape = br#"{"to":"cedar cinder","files":[],"text":"look at this machine"}"#;
match send_from_ui(ok_shape) {
Ok(_) => {}
Err((_, msg)) => assert!(
!msg.contains("nothing to send") && !msg.contains("no files"),
"a note-only send was refused as empty: {msg}"
),
}
}
#[test]
fn a_pasted_invite_is_parsed_without_adding_anything() {
for junk in [
r#"{"text":""}"#,
r#"{"text":"hello"}"#,
r#"{"text":"gage add bob"}"#,
r#"{"text":"gage add"}"#,
] {
let e = parse_invite(junk).expect_err("accepted text with no key");
assert_eq!(e.0, "400 Bad Request", "{junk}: {}", e.1);
}
let notkey = format!(r#"{{"text":"gage add bob {}="}}"#, "!".repeat(43));
let e = parse_invite(¬key).expect_err("accepted a non-key");
assert_eq!(e.0, "400 Bad Request");
let big = format!(r#"{{"text":"{}"}}"#, "a".repeat(5000));
let e = parse_invite(&big).expect_err("accepted an oversized paste");
assert_eq!(e.0, "400 Bad Request");
assert!(e.1.contains("more text than an invite"), "{}", e.1);
}
#[test]
fn parsing_an_invite_never_returns_the_fingerprint() {
let key = crate::words::pairs("x", 1); let _ = key;
let good = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, [7u8; 32]);
let body = serde_json::json!({ "text": format!("gage add 'icy dune' {good}") }).to_string();
let out = match parse_invite(&body) {
Ok(o) => o,
Err(_) => return,
};
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(v.get("key").unwrap().as_str().unwrap(), good);
assert!(v.get("fingerprint").is_none(), "route leaked a fingerprint");
let pairs = crate::words::pairs(good.as_str(), 4);
for p in &pairs {
assert!(!out.contains(p.as_str()), "route leaked the pair {p}");
}
let n = v.get("ask_pair").unwrap().as_u64().unwrap();
assert!((1..=4).contains(&n), "ask_pair was {n}");
}
#[test]
fn the_page_and_the_cli_accept_the_same_shapes() {
let good = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, [9u8; 32]);
for text in [
format!("gage add 'icy dune' {good}"),
format!(" gage add 'icy dune' {good} "),
format!("gage add bob {good}"),
good.clone(),
] {
let (_, token) = crate::quickstart::parse_add(&text);
assert_eq!(
token.as_deref(),
Some(good.as_str()),
"the CLI parser missed {text:?}"
);
}
}
#[test]
fn the_invite_is_markup_fetched_from_this_server() {
assert!(INDEX.contains("fetch('/api/invite')"));
assert!(INDEX.contains("function inviteOk"));
assert!(INDEX.contains("function budpaneHtml"));
assert!(INDEX.contains("function inviteHtml"));
let words = INDEX
.lines()
.find(|l| l.contains("W.stage==='words'"))
.unwrap_or_else(|| panic!("words stage missing"));
assert!(
words.contains("inviteHtml()"),
"the first-run words stage does not show the code"
);
let pane = INDEX
.split("function budpaneHtml")
.nth(1)
.unwrap_or_else(|| panic!("budpaneHtml missing"));
let pane = &pane[..pane.find("\nfunction ").unwrap_or(pane.len())];
assert!(pane.contains("inviteHtml()"));
assert!(!INDEX.contains("<img"));
let clipboard = INDEX
.split("async function pasteClipboard")
.nth(1)
.unwrap_or_else(|| panic!("paste clipboard handler missing"));
let clipboard = &clipboard[..clipboard.find("\n}").unwrap_or(clipboard.len())];
assert!(clipboard.contains("navigator.clipboard.readText()"));
assert!(clipboard.contains("Keep the editable paste field as the fallback"));
assert!(INDEX.contains("onclick=\"pasteClipboard()\""));
assert_eq!(INDEX.matches("navigator.clipboard.readText()").count(), 1);
assert!(INDEX.contains("fetch('/api/parse-invite'"));
assert!(INDEX.contains("value=\"${esc(ANAME)}\""));
assert!(INDEX.contains("${esc(PASTED)}"));
assert!(INDEX.contains("${esc(shown)}"));
assert!(INDEX.contains("confirm:ACONF"));
assert!(INDEX.contains("confirm:p"));
assert!(INDEX.contains("All four match -- add them"));
assert!(INDEX.contains("onclick=\"pasteClipboard()\""));
assert!(INDEX.contains("budReveal('${esc(q.key)}')"));
assert!(INDEX.contains("budConnect('${esc(q.key)}')"));
assert!(INDEX.contains("Suggested name (edit as needed)"));
assert!(!INDEX.contains("http://") && !INDEX.contains("https://"));
assert!(
INDEX.contains("inviteLoad(true)"),
"creating an identity does not load the code"
);
}
#[test]
fn the_first_run_warning_is_the_clis_own_trade() {
assert!(BACKUP_WARNING.contains("lose the key, lose everything"));
assert!(BACKUP_WARNING.contains("gage export-identity"));
}
}