1use 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
17const READ_TIMEOUT_SECS: u64 = 15;
19const MAX_CONN_BYTES: u64 = (1 << 20) + (128 << 10);
22const MAX_HEADER_BYTES: usize = 64 * 1024;
24const MAX_HEADERS: usize = 100;
25const REQUEST_DEADLINE_SECS: u64 = 30;
29
30pub struct UiServer {
31 facade: DejaDbFacade,
32 executor: CalExecutor,
33 db_label: String,
34 token: Option<String>,
38 auth_all: bool,
42 segment_dir: Option<String>,
44 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 pub fn allow_remote(mut self, yes: bool) -> Self {
68 self.allow_remote = yes;
69 self
70 }
71
72 pub fn with_auth(mut self, token: String) -> Self {
79 self.token = Some(token);
80 self.auth_all = true;
81 self
82 }
83
84 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 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 self.allow_remote = true;
105 Ok(self)
106 }
107
108 pub fn bind(addr: &str) -> std::io::Result<TcpListener> {
110 TcpListener::bind(addr)
111 }
112
113 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 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 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 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; }
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 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 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 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 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 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 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 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 "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 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 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 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 Err(_) => { row["missing"] = json!(true); }
486 }
487 idx.insert(hex, rows.len());
488 rows.push(row);
489 }
490 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 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
588fn 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
600fn 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('&', "&").replace('<', "<").replace('>', ">")
654}
655
656fn 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
669fn 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
697fn 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
705fn 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")); assert!(ct_eq(b"", b""));
736 }
737
738 #[test]
739 fn base64_decode_roundtrips_known_vectors() {
740 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 assert_eq!(base64_decode("Zg").unwrap(), b"f");
748 assert!(base64_decode("****").is_none());
750 }
751
752 #[test]
753 fn basic_auth_extracts_password_ignoring_username() {
754 assert_eq!(basic_auth_password("ZGVqYTpzM2NyZXQ=").as_deref(), Some("s3cret"));
756 assert_eq!(basic_auth_password("OnMzY3JldA==").as_deref(), Some("s3cret"));
757 assert_eq!(basic_auth_password("dTphOmI=").as_deref(), Some("a:b"));
760 assert_eq!(basic_auth_password("bm9jb2xvbg=="), None); assert_eq!(basic_auth_password("****"), None); }
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 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}