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 {
if 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)
}
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)");
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'; 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", "/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())
}
("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/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 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()
}
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 buddies: Vec<serde_json::Value> = contacts
.keys
.iter()
.map(|(k, n)| {
serde_json::json!({
"name": n,
"key": k,
"fingerprint": crate::words::fingerprint_of(k, contacts.box_keys.get(k).map(|s| s.as_str())),
"label": crate::words::label(k),
"sealable": contacts.box_keys.contains_key(k),
"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();
serde_json::json!({
"verdicts_stale": !observed,
"relay": relay,
"buddies": buddies,
"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::general_purpose::STANDARD as B64, Engine};
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();
if files.is_empty() {
return Err(("400 Bad Request", "no files".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();
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 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,
})
.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) }
})
.map_err(|e| ("400 Bad Request", e))
}
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
}
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).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 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,
});
let _ = std::fs::remove_dir_all(&tmp);
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,
"agent": m.agent.as_ref().map(|a| serde_json::json!({
"name": a.name, "model": a.model })),
})
})
.collect()
};
serde_json::json!({
"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,
})
.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 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(),
"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");
#[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 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());
}
}