Skip to main content

dejadb_server/
lib.rs

1//! dejadb-server — the opt-in thin HTTP surface and
2//! the local test console (`deja ui`).
3//!
4//! Deliberately minimal: std-only HTTP/1.1 on 127.0.0.1, one request per
5//! connection, JSON API + one embedded HTML page. This is a *local
6//! inspection console*, not a service — no auth, binds loopback only.
7
8use std::io::{BufRead, BufReader, Read, Write};
9use std::net::{TcpListener, TcpStream};
10use std::time::Duration;
11
12use dejadb_cal::{CalExecutor, CalExecutorConfig, CalStoreFacade, DejaDbFacade};
13use serde_json::{json, Value};
14
15const CONSOLE_HTML: &str = include_str!("console.html");
16
17/// Per-connection read/write timeout — bounds slow-client (slowloris) attacks.
18const READ_TIMEOUT_SECS: u64 = 15;
19/// Hard ceiling on bytes read from one connection (1 MiB body cap + headroom
20/// for the request line and headers). Bounds memory against oversized requests.
21const MAX_CONN_BYTES: u64 = (1 << 20) + (128 << 10);
22/// Cap on total header bytes and header count per request.
23const MAX_HEADER_BYTES: usize = 64 * 1024;
24const MAX_HEADERS: usize = 100;
25/// Total wall-clock deadline for reading + handling one request. A per-read
26/// timeout cannot bound a slow-drip client (each byte resets it), so a watchdog
27/// shuts the socket down at this deadline.
28const REQUEST_DEADLINE_SECS: u64 = 30;
29
30pub struct UiServer {
31    facade: DejaDbFacade,
32    executor: CalExecutor,
33    db_label: String,
34    /// Shared secret required for access when set. In hub mode it guards only
35    /// mutating/sync endpoints (`Bearer`); in console-auth mode (`auth_all`) it
36    /// guards every request via `Bearer` **or** HTTP `Basic` (password = token).
37    token: Option<String>,
38    /// When true, *every* request requires the token — the console page, all
39    /// reads, and all writes — and a `401` carries `WWW-Authenticate: Basic` so
40    /// browsers prompt. Set by [`UiServer::with_auth`]; hub mode leaves it off.
41    auth_all: bool,
42    /// Directory for received segments (dejad hub mode).
43    segment_dir: Option<String>,
44    /// When false (the default), reject any request whose `Host` header is not
45    /// loopback — the standard DNS-rebinding defense for a localhost service.
46    /// Set true only when the operator intentionally serves to other hosts
47    /// (CLI `--allow-remote`), where a non-loopback `Host` is expected.
48    allow_remote: bool,
49}
50
51impl UiServer {
52    pub fn new(facade: DejaDbFacade, db_label: String) -> Self {
53        UiServer {
54            facade,
55            executor: CalExecutor::new(CalExecutorConfig::default()),
56            db_label,
57            token: None,
58            auth_all: false,
59            segment_dir: None,
60            allow_remote: false,
61        }
62    }
63
64    /// Accept requests whose `Host` header is non-loopback. Off by default so
65    /// the loopback console is protected against DNS-rebinding reads even when
66    /// unauthenticated; the CLI enables it under `--allow-remote`.
67    pub fn allow_remote(mut self, yes: bool) -> Self {
68        self.allow_remote = yes;
69        self
70    }
71
72    /// Require a shared secret on **every** request (the console page, reads,
73    /// and writes). Browsers authenticate through the native `Basic` prompt
74    /// (any username; password = `token`); scripts may send `Authorization:
75    /// Bearer <token>`. Use this to serve the console to more than a single
76    /// trusted local operator. Pair with a TLS-terminating proxy for non-local
77    /// exposure — the token crosses the wire in the clear otherwise.
78    pub fn with_auth(mut self, token: String) -> Self {
79        self.token = Some(token);
80        self.auth_all = true;
81        self
82    }
83
84    /// Permit or forbid destructive CAL (`FORGET <hash>`) from the query
85    /// console. Enabled by default; pass `false` to serve a read-only console.
86    /// Since the plain console is unauthenticated, disabling this is the safe
87    /// choice when the console is exposed beyond a trusted local operator.
88    pub fn allow_destructive_ops(mut self, allow: bool) -> Self {
89        self.executor = CalExecutor::new(CalExecutorConfig {
90            allow_destructive_ops: allow,
91            ..CalExecutorConfig::default()
92        });
93        self
94    }
95
96    /// dejad mode: require `Authorization: Bearer <token>` on POST
97    /// endpoints and accept segment push/pull under `dir`.
98    pub fn into_hub(mut self, token: Option<String>, dir: &str) -> std::io::Result<Self> {
99        std::fs::create_dir_all(dir)?;
100        self.token = token;
101        self.segment_dir = Some(dir.to_string());
102        // The hub is a network service (segment push/pull from other nodes), so
103        // non-loopback Hosts are expected — writes stay bearer-gated.
104        self.allow_remote = true;
105        Ok(self)
106    }
107
108    /// Bind and return the listener (lets callers learn the ephemeral port).
109    pub fn bind(addr: &str) -> std::io::Result<TcpListener> {
110        TcpListener::bind(addr)
111    }
112
113    /// Serve forever on an already-bound listener.
114    pub fn serve(&self, listener: TcpListener) -> std::io::Result<()> {
115        for stream in listener.incoming() {
116            match stream {
117                Ok(s) => {
118                    if let Err(e) = self.handle(s) {
119                        eprintln!("deja ui: request error: {e}");
120                    }
121                }
122                Err(e) => eprintln!("deja ui: accept error: {e}"),
123            }
124        }
125        Ok(())
126    }
127
128    fn handle(&self, stream: TcpStream) -> std::io::Result<()> {
129        // The server handles one connection at a time, so a single slow-drip
130        // client could otherwise hold it open indefinitely (a per-read timeout
131        // resets on every byte). A watchdog thread enforces a hard wall-clock
132        // deadline by shutting the socket down; it is unparked and joined the
133        // instant the request completes, so it adds no latency in the fast path.
134        let watchdog_stream = stream.try_clone()?;
135        let done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
136        let watchdog_done = std::sync::Arc::clone(&done);
137        let watchdog = std::thread::spawn(move || {
138            std::thread::park_timeout(Duration::from_secs(REQUEST_DEADLINE_SECS));
139            if !watchdog_done.load(std::sync::atomic::Ordering::Acquire) {
140                let _ = watchdog_stream.shutdown(std::net::Shutdown::Both);
141            }
142        });
143        let result = self.handle_request(stream);
144        done.store(true, std::sync::atomic::Ordering::Release);
145        watchdog.thread().unpark();
146        let _ = watchdog.join();
147        result
148    }
149
150    fn handle_request(&self, stream: TcpStream) -> std::io::Result<()> {
151        // Bound slow clients (slowloris) and total bytes read from the
152        // connection so a malicious client cannot stall the server or exhaust
153        // memory with an oversized request line / headers / body.
154        stream.set_read_timeout(Some(Duration::from_secs(READ_TIMEOUT_SECS)))?;
155        stream.set_write_timeout(Some(Duration::from_secs(READ_TIMEOUT_SECS)))?;
156        let mut reader = BufReader::new(stream.try_clone()?.take(MAX_CONN_BYTES));
157        let mut request_line = String::new();
158        reader.read_line(&mut request_line)?;
159        let mut parts = request_line.split_whitespace();
160        let method = parts.next().unwrap_or("").to_string();
161        let path = parts.next().unwrap_or("/").to_string();
162
163        // headers → content-length + authorization + origin + host
164        let mut content_length = 0usize;
165        let mut bearer: Option<String> = None;
166        let mut origin: Option<String> = None;
167        let mut host: Option<String> = None;
168        let mut header_bytes = 0usize;
169        let mut header_count = 0usize;
170        loop {
171            let mut line = String::new();
172            let n = reader.read_line(&mut line)?;
173            if n == 0 {
174                break; // EOF / connection closed by peer
175            }
176            header_bytes += n;
177            header_count += 1;
178            if header_bytes > MAX_HEADER_BYTES || header_count > MAX_HEADERS {
179                return Err(std::io::Error::new(
180                    std::io::ErrorKind::InvalidData,
181                    "request headers too large",
182                ));
183            }
184            let l = line.trim();
185            if l.is_empty() {
186                break;
187            }
188            let low = l.to_ascii_lowercase();
189            if let Some(v) = low.strip_prefix("content-length:") {
190                content_length = v.trim().parse().unwrap_or(0);
191            }
192            if let Some(v) = low.strip_prefix("origin:") {
193                origin = Some(v.trim().to_string());
194            }
195            if let Some(v) = low.strip_prefix("host:") {
196                host = Some(v.trim().to_string());
197            }
198            if low.starts_with("authorization:") {
199                if let Some((_, v)) = l.split_once(':') {
200                    let v = v.trim();
201                    // Accept `Bearer <token>` (scripts/CLI) or HTTP `Basic`
202                    // (browsers, via the native login prompt). For Basic the
203                    // credential is base64(user:pass) and the token is the
204                    // password; the username is ignored.
205                    bearer = if let Some(t) =
206                        v.strip_prefix("Bearer ").or_else(|| v.strip_prefix("bearer "))
207                    {
208                        Some(t.trim().to_string())
209                    } else if let Some(b64) =
210                        v.strip_prefix("Basic ").or_else(|| v.strip_prefix("basic "))
211                    {
212                        basic_auth_password(b64.trim())
213                    } else {
214                        Some(v.to_string())
215                    };
216                }
217            }
218        }
219        // DNS-rebinding defense: a browser tricked into pointing an attacker
220        // domain at 127.0.0.1 sends that domain in the Host header. Unless the
221        // operator opted into remote serving, reject any non-loopback Host on
222        // *every* method (the Origin check below only covers POST, so this is
223        // what stops a drive-by page from reading memory over GET). A missing
224        // Host (bare HTTP/1.0, some CLI clients) passes, mirroring Origin.
225        if !self.allow_remote && host.as_deref().is_some_and(|h| !host_is_local(h)) {
226            let payload = br#"{"ok":false,"error":"non-loopback Host rejected (use --allow-remote to serve remotely)"}"#;
227            let mut out = stream;
228            write!(
229                out,
230                "HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
231                payload.len()
232            )?;
233            out.write_all(payload)?;
234            return out.flush();
235        }
236
237        let mut body = vec![0u8; content_length.min(1 << 20)];
238        if content_length > 0 {
239            reader.read_exact(&mut body)?;
240        }
241
242        // Cross-origin drive-by protection: browsers attach an Origin header
243        // to cross-site requests. Only loopback pages (the console itself)
244        // may mutate; curl/CLI clients send no Origin and pass through.
245        if method == "POST" && origin.as_deref().is_some_and(|o| !origin_is_local(o)) {
246            let payload = br#"{"ok":false,"error":"cross-origin request rejected"}"#;
247            let mut out = stream;
248            write!(
249                out,
250                "HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
251                payload.len()
252            )?;
253            out.write_all(payload)?;
254            return out.flush();
255        }
256
257        let (status, ctype, payload) = self.route(&method, &path, &body, bearer.as_deref());
258        // On a console-auth 401, challenge with Basic so browsers show the
259        // native login prompt (any username; password = token).
260        let auth_challenge = if self.auth_all && status.starts_with("401") {
261            "WWW-Authenticate: Basic realm=\"DejaDB console\", charset=\"UTF-8\"\r\n"
262        } else {
263            ""
264        };
265        let mut out = stream;
266        write!(
267            out,
268            "HTTP/1.1 {status}\r\nContent-Type: {ctype}\r\n{auth_challenge}Content-Length: {}\r\nConnection: close\r\n\r\n",
269            payload.len()
270        )?;
271        out.write_all(&payload)?;
272        out.flush()
273    }
274
275    fn route(&self, method: &str, path: &str, body: &[u8], bearer: Option<&str>) -> (&'static str, &'static str, Vec<u8>) {
276        // Auth: console-auth mode (`auth_all`) guards every request; hub mode
277        // guards only mutating + sync endpoints. The credential arrives as a
278        // Bearer token or an HTTP Basic password (see `handle_request`).
279        if let Some(tok) = &self.token {
280            let guarded = self.auth_all || method == "POST" || path.starts_with("/api/segment");
281            let authorized = bearer.is_some_and(|b| ct_eq(b.as_bytes(), tok.as_bytes()));
282            if guarded && !authorized {
283                return ("401 Unauthorized", "application/json",
284                        br#"{"ok":false,"error":"authentication required"}"#.to_vec());
285            }
286        }
287        let (path, query) = match path.split_once('?') {
288            Some((p, q)) => (p, q),
289            None => (path, ""),
290        };
291        let q = |key: &str| -> Option<String> {
292            query.split('&').find_map(|kv| {
293                let (k, v) = kv.split_once('=')?;
294                (k == key).then(|| urldecode(v))
295            })
296        };
297        match (method, path) {
298            ("GET", "/") => (
299                "200 OK",
300                "text/html; charset=utf-8",
301                CONSOLE_HTML
302                    .replace("{{DB}}", &html_escape(&self.db_label))
303                    .into_bytes(),
304            ),
305            ("POST", "/api/cal") => {
306                let req: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
307                let queryt = req.get("query").and_then(|v| v.as_str()).unwrap_or("");
308                match self.executor.execute(queryt, &self.facade) {
309                    Ok(res) => ok_json(json!({
310                        "ok": true,
311                        "result": res.result,
312                        "warnings": res.warnings,
313                        "statement": res.metadata.statement_type,
314                        "elapsed_ms": res.metadata.execution_time_ms,
315                    })),
316                    Err(e) => {
317                        // Structured error: code + span + hint let the console
318                        // point at the offending token instead of just quoting.
319                        let mut err = json!({
320                            "ok": false,
321                            "error": e.sanitize_for_client(),
322                            "code": e.code(),
323                        });
324                        if let Some(sp) = e.span() {
325                            err["span"] = json!({
326                                "start": sp.start, "end": sp.end,
327                                "line": sp.line, "col": sp.col,
328                            });
329                        }
330                        if let Some(hint) = e.suggestion() {
331                            err["suggestion"] = json!(hint);
332                        }
333                        ok_json(err)
334                    }
335                }
336            }
337            ("GET", "/api/stats") => {
338                let stats = self.facade.with_store(|m| m.stats());
339                match stats {
340                    Ok(s) => ok_json(json!({
341                        "db": self.db_label,
342                        "grains": s.grains, "current": s.current,
343                        "triples": s.triples, "terms": s.terms,
344                        "ops": s.ops, "events_indexed": s.events_indexed,
345                    })),
346                    Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
347                }
348            }
349            ("GET", "/api/log") => {
350                let since: i64 = q("since").and_then(|v| v.parse().ok()).unwrap_or(0);
351                let limit: usize = q("limit").and_then(|v| v.parse().ok()).unwrap_or(100);
352                match self.facade.with_store(|m| m.changes_since(since, limit)) {
353                    Ok(ops) => {
354                        let rows: Vec<Value> = ops
355                            .iter()
356                            .map(|o| {
357                                json!({
358                                    "op_seq": o.op_seq, "hlc": o.hlc,
359                                    "op": match o.op { 1 => "add", 2 => "supersede", 3 => "forget", _ => "?" },
360                                    "hash": o.hash.to_hex(),
361                                })
362                            })
363                            .collect();
364                        ok_json(json!(rows))
365                    }
366                    Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
367                }
368            }
369            ("GET", "/api/config") => {
370                // Read-only observability: the *effective* per-process
371                // configuration. Nothing here is persisted in the .db —
372                // the file holds data only; these values are supplied by
373                // the host at open time.
374                let cfg = self.executor.config();
375                let (index_text, embedder_dim, declared_embed, mut warnings) =
376                    self.facade.with_store(|m| {
377                        (
378                            m.index_text_enabled(),
379                            m.embedder_dim(),
380                            m.declared_embedding().map(|(mm, d)| (mm.to_string(), d)),
381                            m.open_warnings().to_vec(),
382                        )
383                    });
384                if let Some((m, d)) = &declared_embed {
385                    if embedder_dim.is_none() {
386                        warnings.push(format!(
387                            "vector leg dormant: file expects {m}@{d}, no embedding backend installed"
388                        ));
389                    }
390                }
391                ok_json(json!({
392                    "ok": true,
393                    "db": self.db_label,
394                    "warnings": warnings,
395                    "file": {
396                        "text_index": index_text,
397                        "embedding": declared_embed.map(|(m, d)| json!({"model": m, "dim": d})),
398                    },
399                    "session": {
400                        "namespace": self.facade.session_namespace(),
401                        "mounts": self.facade.mount_aliases(),
402                    },
403                    "store": {
404                        "index_text": index_text,
405                        "embedder": embedder_dim.map(|d| json!({"dim": d})),
406                    },
407                    "recall": {
408                        "fusion": "rrf",
409                        "rrf_k0": dejadb_store::RRF_K0,
410                        "overfetch_factor": DejaDbFacade::RECALL_OVERFETCH,
411                        "legs": {
412                            "structural": true,
413                            "bm25": index_text,
414                            "vector": embedder_dim.is_some(),
415                        },
416                    },
417                    "executor": {
418                        "max_limit": cfg.max_limit,
419                        "default_limit": cfg.default_limit,
420                        "tier1_writes": cfg.tier1_enabled,
421                        "namespace_override": cfg.namespace_override,
422                        "user_id_override": cfg.user_id_override,
423                    },
424                    "server": {
425                        "hub_mode": self.segment_dir.is_some(),
426                        "auth_required": self.token.is_some(),
427                        // true = every request is authenticated (console-auth);
428                        // false with auth_required = hub mode (writes/sync only).
429                        "auth_all": self.auth_all,
430                        "segment_dir": self.segment_dir,
431                    },
432                    "persistence": "per-process (host-supplied at open) — not stored in the .db",
433                }))
434            }
435            ("GET", "/api/browse") => {
436                // Browse-without-queries: the tail of the op-log joined with
437                // grain summaries, newest first. Supersession and tombstone
438                // status are resolved within the returned window so the
439                // console can dim/strike them (grains are never mutated —
440                // this reads the index + immutable blobs only).
441                let limit: i64 = q("limit")
442                    .and_then(|v| v.parse().ok())
443                    .unwrap_or(500)
444                    .clamp(1, 2000);
445                let built = self.facade.with_store(|m| -> Result<(i64, Vec<Value>), String> {
446                    let total = m.stats().map_err(|e| e.to_string())?.ops as i64;
447                    let after = (total - limit).max(0);
448                    let ops = m.changes_since(after, limit as usize).map_err(|e| e.to_string())?;
449                    let mut rows: Vec<Value> = Vec::with_capacity(ops.len());
450                    let mut idx: std::collections::HashMap<String, usize> =
451                        std::collections::HashMap::new();
452                    for o in &ops {
453                        let hex = o.hash.to_hex();
454                        let op_name = match o.op { 1 => "add", 2 => "supersede", 3 => "forget", _ => "?" };
455                        if o.op == 3 {
456                            // tombstone: flag the earlier row if it is in the
457                            // window, else emit a stub (blob is already gone).
458                            match idx.get(&hex) {
459                                Some(&i) => { rows[i]["forgotten"] = json!(true); }
460                                None => rows.push(json!({
461                                    "hash": hex, "op_seq": o.op_seq, "hlc": o.hlc,
462                                    "op": op_name, "forgotten": true,
463                                })),
464                            }
465                            continue;
466                        }
467                        // A SUPERSEDE logs two ops for the new grain (add +
468                        // supersede) — merge them into one row, keeping the
469                        // later op label.
470                        if let Some(&i) = idx.get(&hex) {
471                            rows[i]["op"] = json!(op_name);
472                            rows[i]["op_seq"] = json!(o.op_seq);
473                            rows[i]["hlc"] = json!(o.hlc);
474                            continue;
475                        }
476                        let mut row = json!({
477                            "hash": hex, "op_seq": o.op_seq, "hlc": o.hlc, "op": op_name,
478                        });
479                        match m.get(&o.hash) {
480                            Ok(g) => {
481                                row["type"] = json!(format!("{:?}", g.grain_type).to_lowercase());
482                                row["fields"] = serde_json::to_value(&g.fields).unwrap_or(Value::Null);
483                            }
484                            // erased since (forget op outside the window)
485                            Err(_) => { row["missing"] = json!(true); }
486                        }
487                        idx.insert(hex, rows.len());
488                        rows.push(row);
489                    }
490                    // A supersede op's grain points at its predecessor via
491                    // derived_from; mark the predecessor if it is in-window.
492                    let marks: Vec<(String, String)> = rows.iter()
493                        .filter(|r| r["op"] == "supersede")
494                        .filter_map(|r| {
495                            let old = r["fields"].get("derived_from")?.as_str()?;
496                            Some((old.to_string(), r["hash"].as_str()?.to_string()))
497                        })
498                        .collect();
499                    for (old, newer) in marks {
500                        if let Some(&i) = idx.get(&old) {
501                            rows[i]["superseded_by"] = json!(newer);
502                        }
503                    }
504                    rows.reverse();
505                    Ok((total, rows))
506                });
507                match built {
508                    Ok((total, rows)) => ok_json(json!({"ok": true, "total_ops": total, "grains": rows})),
509                    Err(e) => ok_json(json!({"ok": false, "error": e})),
510                }
511            }
512            ("GET", "/api/grain") => {
513                let hash = q("hash").unwrap_or_default();
514                match dejadb_core::error::Hash::from_hex(&hash)
515                    .and_then(|h| self.facade.get(&h))
516                {
517                    Ok(g) => ok_json(json!({
518                        "hash": g.hash.to_hex(),
519                        "type": format!("{:?}", g.grain_type).to_lowercase(),
520                        "fields": g.fields,
521                    })),
522                    Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
523                }
524            }
525            ("GET", "/api/verify") => match self.facade.with_store(|m| m.verify()) {
526                Ok(r) => ok_json(json!({
527                    "integrity": r.integrity, "grains": r.grains,
528                    "hash_mismatches": r.hash_mismatches, "undecodable": r.undecodable,
529                })),
530                Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
531            },
532            ("POST", "/api/segment") => {
533                // push: body = raw MGB1 bundle; applied immediately + archived
534                let Some(dir) = &self.segment_dir else {
535                    return ("400 Bad Request", "application/json",
536                            br#"{"ok":false,"error":"not in hub mode"}"#.to_vec());
537                };
538                let name = q("name").unwrap_or_else(|| format!("push-{}.mgb", now_label()));
539                let Some(safe) = safe_segment_name(&name) else {
540                    return ("400 Bad Request", "application/json",
541                            br#"{"ok":false,"error":"invalid segment name"}"#.to_vec());
542                };
543                let path = format!("{dir}/{safe}");
544                if std::fs::write(&path, body).is_err() {
545                    return ("500 Internal Server Error", "application/json",
546                            br#"{"ok":false,"error":"write failed"}"#.to_vec());
547                }
548                match self.facade.with_store(|m| m.import_bundle(&path)) {
549                    Ok(st) => ok_json(json!({"ok": true, "applied": st.applied, "skipped": st.skipped, "stored": safe})),
550                    Err(e) => ok_json(json!({"ok": false, "error": e.to_string()})),
551                }
552            }
553            ("GET", "/api/segments") => {
554                let Some(dir) = &self.segment_dir else {
555                    return ok_json(json!([]));
556                };
557                let mut names: Vec<String> = std::fs::read_dir(dir)
558                    .map(|d| d.filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().to_string()))
559                        .filter(|n| n.ends_with(".mgb")).collect())
560                    .unwrap_or_default();
561                names.sort();
562                ok_json(json!(names))
563            }
564            ("GET", "/api/segment") => {
565                let Some(dir) = &self.segment_dir else {
566                    return ("400 Bad Request", "application/json", b"{}".to_vec());
567                };
568                let name = q("name").unwrap_or_default();
569                let Some(safe) = safe_segment_name(&name) else {
570                    return ("400 Bad Request", "application/json",
571                            br#"{"ok":false,"error":"invalid segment name"}"#.to_vec());
572                };
573                match std::fs::read(format!("{dir}/{safe}")) {
574                    Ok(b) => ("200 OK", "application/octet-stream", b),
575                    Err(_) => ("404 Not Found", "application/json",
576                               br#"{"ok":false,"error":"no such segment"}"#.to_vec()),
577                }
578            }
579            _ => (
580                "404 Not Found",
581                "application/json",
582                br#"{"ok":false,"error":"not found"}"#.to_vec(),
583            ),
584        }
585    }
586}
587
588/// True when a `host[:port]` (or `[ipv6]:port`) authority names a loopback
589/// host. Shared by the Origin drive-by check and the Host-header
590/// (DNS-rebinding) check.
591fn host_is_local(host_port: &str) -> bool {
592    let host = if let Some(h) = host_port.strip_prefix('[') {
593        h.split(']').next().unwrap_or("")
594    } else {
595        host_port.split(':').next().unwrap_or("")
596    };
597    matches!(host, "127.0.0.1" | "localhost" | "::1")
598}
599
600/// True when an Origin header names a loopback host (any port, http/https).
601fn origin_is_local(origin: &str) -> bool {
602    let rest = origin
603        .strip_prefix("http://")
604        .or_else(|| origin.strip_prefix("https://"));
605    let Some(rest) = rest else { return false };
606    let host_port = rest.split('/').next().unwrap_or("");
607    host_is_local(host_port)
608}
609
610fn now_label() -> String {
611    format!("{}", std::time::SystemTime::now()
612        .duration_since(std::time::UNIX_EPOCH)
613        .map(|d| d.as_millis())
614        .unwrap_or(0))
615}
616
617fn ok_json(v: Value) -> (&'static str, &'static str, Vec<u8>) {
618    ("200 OK", "application/json", v.to_string().into_bytes())
619}
620
621fn urldecode(s: &str) -> String {
622    let mut out = Vec::new();
623    let b = s.as_bytes();
624    let mut i = 0;
625    while i < b.len() {
626        match b[i] {
627            b'%' if i + 2 < b.len() + 1 && i + 2 < b.len() + 1 => {
628                if i + 2 < b.len() + 1 && i + 2 < b.len() {
629                    let hex = std::str::from_utf8(&b[i + 1..i + 3]).unwrap_or("");
630                    if let Ok(v) = u8::from_str_radix(hex, 16) {
631                        out.push(v);
632                        i += 3;
633                        continue;
634                    }
635                }
636                out.push(b[i]);
637                i += 1;
638            }
639            b'+' => {
640                out.push(b' ');
641                i += 1;
642            }
643            c => {
644                out.push(c);
645                i += 1;
646            }
647        }
648    }
649    String::from_utf8_lossy(&out).to_string()
650}
651
652fn html_escape(s: &str) -> String {
653    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
654}
655
656/// Constant-time byte comparison — avoids leaking the bearer token through
657/// response timing. A length mismatch fails fast (token length is not secret).
658fn ct_eq(a: &[u8], b: &[u8]) -> bool {
659    if a.len() != b.len() {
660        return false;
661    }
662    let mut diff = 0u8;
663    for (x, y) in a.iter().zip(b.iter()) {
664        diff |= x ^ y;
665    }
666    diff == 0
667}
668
669/// Decode standard base64 (RFC 4648), enough for HTTP Basic credentials.
670/// Hand-rolled to keep the server dependency-free. Returns `None` on any
671/// invalid character or truncated input.
672fn base64_decode(s: &str) -> Option<Vec<u8>> {
673    fn val(c: u8) -> Option<u32> {
674        match c {
675            b'A'..=b'Z' => Some((c - b'A') as u32),
676            b'a'..=b'z' => Some((c - b'a' + 26) as u32),
677            b'0'..=b'9' => Some((c - b'0' + 52) as u32),
678            b'+' => Some(62),
679            b'/' => Some(63),
680            _ => None,
681        }
682    }
683    let s = s.trim().trim_end_matches('=');
684    let mut out = Vec::with_capacity(s.len() * 3 / 4);
685    let (mut acc, mut bits) = (0u32, 0u32);
686    for &c in s.as_bytes() {
687        acc = (acc << 6) | val(c)?;
688        bits += 6;
689        if bits >= 8 {
690            bits -= 8;
691            out.push((acc >> bits) as u8);
692        }
693    }
694    Some(out)
695}
696
697/// Extract the password from an HTTP Basic credential (`base64("user:pass")`).
698/// The console ignores the username and treats the password as the token.
699/// Returns `None` if the value is not valid base64/UTF-8 or has no `:`.
700fn basic_auth_password(b64: &str) -> Option<String> {
701    let text = String::from_utf8(base64_decode(b64)?).ok()?;
702    text.split_once(':').map(|(_user, pass)| pass.to_string())
703}
704
705/// Sanitize a client-supplied segment name into a single safe filename.
706/// Strips anything outside `[A-Za-z0-9-._]`, then requires the result to be
707/// exactly one normal path component — rejecting empty names, `.`/`..`, and
708/// path separators so a push/pull cannot escape the segment directory.
709fn safe_segment_name(name: &str) -> Option<String> {
710    let safe: String = name
711        .chars()
712        .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_'))
713        .collect();
714    if safe.is_empty() || safe.len() > 128 {
715        return None;
716    }
717    let mut comps = std::path::Path::new(&safe).components();
718    match (comps.next(), comps.next()) {
719        (Some(std::path::Component::Normal(c)), None) if c.to_str() == Some(safe.as_str()) => {
720            Some(safe)
721        }
722        _ => None,
723    }
724}
725
726#[cfg(test)]
727mod security_tests {
728    use super::{base64_decode, basic_auth_password, ct_eq, safe_segment_name};
729
730    #[test]
731    fn ct_eq_matches_only_equal() {
732        assert!(ct_eq(b"secret-token", b"secret-token"));
733        assert!(!ct_eq(b"secret-token", b"secret-toker"));
734        assert!(!ct_eq(b"secret", b"secret-token")); // length mismatch
735        assert!(ct_eq(b"", b""));
736    }
737
738    #[test]
739    fn base64_decode_roundtrips_known_vectors() {
740        // RFC 4648 test vectors (with and without padding).
741        assert_eq!(base64_decode("").unwrap(), b"");
742        assert_eq!(base64_decode("Zg==").unwrap(), b"f");
743        assert_eq!(base64_decode("Zm8=").unwrap(), b"fo");
744        assert_eq!(base64_decode("Zm9v").unwrap(), b"foo");
745        assert_eq!(base64_decode("Zm9vYmFy").unwrap(), b"foobar");
746        // padding is optional for us
747        assert_eq!(base64_decode("Zg").unwrap(), b"f");
748        // invalid characters are rejected
749        assert!(base64_decode("****").is_none());
750    }
751
752    #[test]
753    fn basic_auth_extracts_password_ignoring_username() {
754        // base64("deja:s3cret") and base64(":s3cret") both yield the token.
755        assert_eq!(basic_auth_password("ZGVqYTpzM2NyZXQ=").as_deref(), Some("s3cret"));
756        assert_eq!(basic_auth_password("OnMzY3JldA==").as_deref(), Some("s3cret"));
757        // a password may itself contain ':' — only the first ':' splits.
758        // base64("u:a:b") -> "a:b"
759        assert_eq!(basic_auth_password("dTphOmI=").as_deref(), Some("a:b"));
760        // no ':' at all → not a valid Basic credential
761        assert_eq!(basic_auth_password("bm9jb2xvbg=="), None); // "nocolon"
762        assert_eq!(basic_auth_password("****"), None); // not base64
763    }
764
765    #[test]
766    fn safe_segment_name_blocks_traversal() {
767        assert_eq!(safe_segment_name("push-123.mgb").as_deref(), Some("push-123.mgb"));
768        assert_eq!(safe_segment_name(".."), None);
769        assert_eq!(safe_segment_name("."), None);
770        assert_eq!(safe_segment_name(""), None);
771        // Separators are stripped, so any accepted name is a single component
772        // that cannot escape the segment directory.
773        for probe in ["../../etc/passwd", "/etc/passwd", "a/../b", "foo/bar"] {
774            if let Some(s) = safe_segment_name(probe) {
775                assert!(!s.contains('/') && s != ".." && s != ".", "unsafe: {s}");
776            }
777        }
778    }
779}