2026-04-20 23:38:02 -06:00
|
|
|
//! # Tree Module
|
|
|
|
|
//!
|
|
|
|
|
//! The `Tree` dispatches incoming [`TreeRequest`]s to registered [`Endpoint`]s
|
|
|
|
|
//! by matching the request's destination path.
|
|
|
|
|
//!
|
|
|
|
|
//! ## Path matching
|
|
|
|
|
//!
|
|
|
|
|
//! Paths are `/`-delimited strings. An `Endpoint` is registered at a path prefix.
|
|
|
|
|
//! A request matches an endpoint if the endpoint's path is a prefix of the request path.
|
|
|
|
|
//! When multiple endpoints match, the one with the **longest** prefix wins.
|
|
|
|
|
//!
|
|
|
|
|
//! ```text
|
|
|
|
|
//! Registered endpoints: Request path:
|
|
|
|
|
//! /shell ← prefix /shell/exec → matches /shell
|
|
|
|
|
//! /files ← prefix /files/read → matches /files
|
|
|
|
|
//! /shell/exec ← more specific /shell/exec → matches /shell/exec (longer)
|
|
|
|
|
//! ```
|
|
|
|
|
//!
|
|
|
|
|
//! ## Usage
|
|
|
|
|
//!
|
|
|
|
|
//! ```rust
|
|
|
|
|
//! use unshell::tree::{Tree, Endpoint};
|
|
|
|
|
//! use unshell::protocol::{
|
|
|
|
|
//! TreeRequest, TreeResponse, RequestType, ResponseStatus, content,
|
|
|
|
|
//! };
|
|
|
|
|
//!
|
|
|
|
|
//! /// A simple echo endpoint that reflects the request data back.
|
|
|
|
|
//! struct EchoEndpoint;
|
|
|
|
|
//!
|
|
|
|
|
//! impl Endpoint for EchoEndpoint {
|
|
|
|
|
//! fn handle(&mut self, request: TreeRequest) -> TreeResponse {
|
|
|
|
|
//! TreeResponse {
|
|
|
|
|
//! request_id: request.request_id,
|
|
|
|
|
//! status: ResponseStatus::Ok,
|
|
|
|
|
//! content_type: request.content_type.clone(),
|
|
|
|
|
//! data: request.data.clone(),
|
|
|
|
|
//! }
|
|
|
|
|
//! }
|
|
|
|
|
//! }
|
|
|
|
|
//!
|
|
|
|
|
//! let mut tree = Tree::new();
|
|
|
|
|
//! tree.register("/echo", EchoEndpoint);
|
|
|
|
|
//!
|
|
|
|
|
//! let response = tree.dispatch(TreeRequest {
|
|
|
|
|
//! request_id: 1,
|
|
|
|
|
//! request_type: RequestType::Read,
|
|
|
|
|
//! content_type: content::UTF8_STRING.into(),
|
|
|
|
|
//! data: b"hello".to_vec(),
|
|
|
|
|
//! }, "/echo/anything");
|
|
|
|
|
//!
|
|
|
|
|
//! assert_eq!(response.status, ResponseStatus::Ok);
|
|
|
|
|
//! assert_eq!(response.data, b"hello");
|
|
|
|
|
//! ```
|
2026-02-09 10:27:15 -07:00
|
|
|
|
2026-04-20 23:38:02 -06:00
|
|
|
extern crate alloc;
|
|
|
|
|
use alloc::borrow::ToOwned;
|
|
|
|
|
use alloc::boxed::Box;
|
|
|
|
|
use alloc::string::String;
|
|
|
|
|
use alloc::vec::Vec;
|
2026-03-17 17:29:36 -06:00
|
|
|
|
2026-04-20 23:38:02 -06:00
|
|
|
use crate::protocol::{
|
|
|
|
|
content, ResponseStatus, TreeRequest, TreeResponse,
|
|
|
|
|
};
|
2026-03-18 12:01:21 -06:00
|
|
|
|
2026-04-20 23:38:02 -06:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Endpoint trait
|
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-03-17 17:29:36 -06:00
|
|
|
|
2026-04-20 23:38:02 -06:00
|
|
|
/// A module that handles [`TreeRequest`]s at a registered path.
|
|
|
|
|
///
|
|
|
|
|
/// Implement this trait to add capabilities to a payload. The `Tree` calls
|
|
|
|
|
/// `handle` when a request's path matches this endpoint's registration prefix.
|
|
|
|
|
///
|
|
|
|
|
/// # Example
|
|
|
|
|
///
|
|
|
|
|
/// ```rust
|
|
|
|
|
/// use unshell::tree::Endpoint;
|
|
|
|
|
/// use unshell::protocol::{TreeRequest, TreeResponse, ResponseStatus, content};
|
|
|
|
|
///
|
|
|
|
|
/// struct PingEndpoint;
|
|
|
|
|
///
|
|
|
|
|
/// impl Endpoint for PingEndpoint {
|
|
|
|
|
/// fn handle(&mut self, request: TreeRequest) -> TreeResponse {
|
|
|
|
|
/// TreeResponse {
|
|
|
|
|
/// request_id: request.request_id,
|
|
|
|
|
/// status: ResponseStatus::Ok,
|
|
|
|
|
/// content_type: content::UTF8_STRING.into(),
|
|
|
|
|
/// data: b"pong".to_vec(),
|
|
|
|
|
/// }
|
|
|
|
|
/// }
|
|
|
|
|
/// }
|
|
|
|
|
/// ```
|
|
|
|
|
pub trait Endpoint: Send {
|
|
|
|
|
/// Handle a request and return a response.
|
|
|
|
|
///
|
|
|
|
|
/// This method is called synchronously on the recv loop thread. It should
|
|
|
|
|
/// not block for extended periods. For long-running operations, spawn a
|
|
|
|
|
/// background thread and return immediately with a `pending` response
|
|
|
|
|
/// (streaming responses are a future protocol feature).
|
|
|
|
|
fn handle(&mut self, request: TreeRequest) -> TreeResponse;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Tree
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
/// A path-addressed dispatcher that routes [`TreeRequest`]s to [`Endpoint`]s.
|
|
|
|
|
///
|
|
|
|
|
/// # Path matching algorithm
|
|
|
|
|
///
|
|
|
|
|
/// The tree uses **longest-prefix matching**:
|
|
|
|
|
/// 1. Split the request path by `/`.
|
|
|
|
|
/// 2. For each registered endpoint, check if the endpoint's path components
|
|
|
|
|
/// are a prefix of the request path components.
|
|
|
|
|
/// 3. Among all matching endpoints, return the one with the most components
|
|
|
|
|
/// (the most specific match).
|
|
|
|
|
/// 4. If no match: return a [`ResponseStatus::NoBranchError`] response.
|
|
|
|
|
///
|
|
|
|
|
/// # Example
|
|
|
|
|
///
|
|
|
|
|
/// ```rust
|
|
|
|
|
/// use unshell::tree::{Tree, Endpoint};
|
|
|
|
|
/// use unshell::protocol::{TreeRequest, TreeResponse, RequestType, ResponseStatus, content};
|
|
|
|
|
///
|
|
|
|
|
/// struct Shell;
|
|
|
|
|
///
|
|
|
|
|
/// impl Endpoint for Shell {
|
|
|
|
|
/// fn handle(&mut self, req: TreeRequest) -> TreeResponse {
|
|
|
|
|
/// TreeResponse {
|
|
|
|
|
/// request_id: req.request_id,
|
|
|
|
|
/// status: ResponseStatus::Ok,
|
|
|
|
|
/// content_type: content::UTF8_STRING.into(),
|
|
|
|
|
/// data: b"shell output".to_vec(),
|
|
|
|
|
/// }
|
|
|
|
|
/// }
|
|
|
|
|
/// }
|
|
|
|
|
///
|
|
|
|
|
/// let mut tree = Tree::new();
|
|
|
|
|
/// tree.register("/shell", Shell);
|
|
|
|
|
///
|
|
|
|
|
/// // A request to /shell/exec/anything matches /shell (the registered prefix).
|
|
|
|
|
/// let resp = tree.dispatch(
|
|
|
|
|
/// TreeRequest {
|
|
|
|
|
/// request_id: 1,
|
|
|
|
|
/// request_type: RequestType::CallProcedure,
|
|
|
|
|
/// content_type: content::NONE.into(),
|
|
|
|
|
/// data: Vec::new(),
|
|
|
|
|
/// },
|
|
|
|
|
/// "/shell/exec",
|
|
|
|
|
/// );
|
|
|
|
|
/// assert_eq!(resp.status, ResponseStatus::Ok);
|
|
|
|
|
/// ```
|
2026-03-17 17:29:36 -06:00
|
|
|
pub struct Tree {
|
2026-04-20 23:38:02 -06:00
|
|
|
/// Registered endpoints with their path prefixes.
|
|
|
|
|
///
|
|
|
|
|
/// The path is stored as a `Vec<String>` of components (split on `/`,
|
|
|
|
|
/// empty leading component from the leading `/` is discarded).
|
|
|
|
|
endpoints: Vec<(Vec<String>, Box<dyn Endpoint>)>,
|
2026-03-17 17:29:36 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Tree {
|
2026-04-20 23:38:02 -06:00
|
|
|
/// Create an empty tree with no registered endpoints.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn new() -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
endpoints: Vec::new(),
|
|
|
|
|
}
|
2026-03-17 17:29:36 -06:00
|
|
|
}
|
2026-04-20 23:38:02 -06:00
|
|
|
|
|
|
|
|
/// Register an endpoint at the given path prefix.
|
|
|
|
|
///
|
|
|
|
|
/// # Arguments
|
|
|
|
|
///
|
|
|
|
|
/// * `path` — the path prefix this endpoint owns, e.g. `"/shell"`.
|
|
|
|
|
/// Leading `/` is stripped; components are split on `/`.
|
|
|
|
|
/// * `endpoint` — the handler that will receive matching requests.
|
|
|
|
|
///
|
|
|
|
|
/// # Panics
|
|
|
|
|
///
|
|
|
|
|
/// Does not panic. Registering the same path twice is allowed; the second
|
|
|
|
|
/// registration shadows the first for that exact path (longest-prefix
|
|
|
|
|
/// matching still applies for sub-paths).
|
|
|
|
|
///
|
|
|
|
|
/// # Example
|
|
|
|
|
///
|
|
|
|
|
/// ```rust
|
|
|
|
|
/// use unshell::tree::{Tree, Endpoint};
|
|
|
|
|
/// use unshell::protocol::{TreeRequest, TreeResponse, ResponseStatus, content};
|
|
|
|
|
///
|
|
|
|
|
/// struct Noop;
|
|
|
|
|
/// impl Endpoint for Noop {
|
|
|
|
|
/// fn handle(&mut self, req: TreeRequest) -> TreeResponse {
|
|
|
|
|
/// TreeResponse {
|
|
|
|
|
/// request_id: req.request_id,
|
|
|
|
|
/// status: ResponseStatus::Ok,
|
|
|
|
|
/// content_type: content::NONE.into(),
|
|
|
|
|
/// data: Vec::new(),
|
|
|
|
|
/// }
|
|
|
|
|
/// }
|
|
|
|
|
/// }
|
|
|
|
|
///
|
|
|
|
|
/// let mut tree = Tree::new();
|
|
|
|
|
/// tree.register("/shell", Noop);
|
|
|
|
|
/// ```
|
|
|
|
|
pub fn register<E: Endpoint + 'static>(&mut self, path: &str, endpoint: E) {
|
|
|
|
|
let components = split_path(path);
|
|
|
|
|
self.endpoints.push((components, Box::new(endpoint)));
|
2026-03-17 17:29:36 -06:00
|
|
|
}
|
|
|
|
|
|
2026-04-20 23:38:02 -06:00
|
|
|
/// Dispatch a request to the best-matching endpoint.
|
|
|
|
|
///
|
|
|
|
|
/// Returns a [`TreeResponse`] with [`ResponseStatus::NoBranchError`]
|
|
|
|
|
/// if no registered endpoint matches the request path.
|
|
|
|
|
///
|
|
|
|
|
/// # Arguments
|
|
|
|
|
///
|
|
|
|
|
/// * `request` — the incoming request.
|
|
|
|
|
/// * `dst_path` — the destination path from the packet header.
|
|
|
|
|
///
|
|
|
|
|
/// # Example
|
|
|
|
|
///
|
|
|
|
|
/// ```rust
|
|
|
|
|
/// use unshell::tree::Tree;
|
|
|
|
|
/// use unshell::protocol::{TreeRequest, RequestType, ResponseStatus, content};
|
|
|
|
|
///
|
|
|
|
|
/// let mut tree = Tree::new();
|
|
|
|
|
/// // (register some endpoints here)
|
|
|
|
|
///
|
|
|
|
|
/// let resp = tree.dispatch(
|
|
|
|
|
/// TreeRequest {
|
|
|
|
|
/// request_id: 99,
|
|
|
|
|
/// request_type: RequestType::Read,
|
|
|
|
|
/// content_type: content::NONE.into(),
|
|
|
|
|
/// data: Vec::new(),
|
|
|
|
|
/// },
|
|
|
|
|
/// "/unknown/path",
|
|
|
|
|
/// );
|
|
|
|
|
/// assert_eq!(resp.status, ResponseStatus::NoBranchError);
|
|
|
|
|
/// ```
|
|
|
|
|
pub fn dispatch(&mut self, request: TreeRequest, dst_path: &str) -> TreeResponse {
|
|
|
|
|
let path_components = split_path(dst_path);
|
2026-03-17 17:29:36 -06:00
|
|
|
|
2026-04-20 23:38:02 -06:00
|
|
|
// Find the endpoint with the longest matching prefix.
|
|
|
|
|
let best = self
|
|
|
|
|
.endpoints
|
|
|
|
|
.iter_mut()
|
|
|
|
|
.filter(|(ep_path, _)| is_prefix(ep_path, &path_components))
|
|
|
|
|
.max_by_key(|(ep_path, _)| ep_path.len());
|
2026-03-17 17:29:36 -06:00
|
|
|
|
2026-04-20 23:38:02 -06:00
|
|
|
match best {
|
|
|
|
|
Some((_, endpoint)) => endpoint.handle(request),
|
|
|
|
|
None => TreeResponse {
|
|
|
|
|
request_id: request.request_id,
|
|
|
|
|
status: ResponseStatus::NoBranchError,
|
|
|
|
|
content_type: content::NONE.into(),
|
|
|
|
|
data: Vec::new(),
|
|
|
|
|
},
|
2026-03-17 17:29:36 -06:00
|
|
|
}
|
2026-04-20 23:38:02 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Return the list of registered path prefixes.
|
|
|
|
|
///
|
|
|
|
|
/// Used during handshake to tell the router which paths this tree owns.
|
|
|
|
|
///
|
|
|
|
|
/// # Example
|
|
|
|
|
///
|
|
|
|
|
/// ```rust
|
|
|
|
|
/// use unshell::tree::{Tree, Endpoint};
|
|
|
|
|
/// use unshell::protocol::{TreeRequest, TreeResponse, ResponseStatus, content};
|
|
|
|
|
///
|
|
|
|
|
/// struct Noop;
|
|
|
|
|
/// impl Endpoint for Noop {
|
|
|
|
|
/// fn handle(&mut self, req: TreeRequest) -> TreeResponse {
|
|
|
|
|
/// TreeResponse {
|
|
|
|
|
/// request_id: req.request_id,
|
|
|
|
|
/// status: ResponseStatus::Ok,
|
|
|
|
|
/// content_type: content::NONE.into(),
|
|
|
|
|
/// data: Vec::new(),
|
|
|
|
|
/// }
|
|
|
|
|
/// }
|
|
|
|
|
/// }
|
|
|
|
|
///
|
|
|
|
|
/// let mut tree = Tree::new();
|
|
|
|
|
/// tree.register("/shell", Noop);
|
|
|
|
|
/// tree.register("/files", Noop);
|
|
|
|
|
///
|
|
|
|
|
/// let paths = tree.registered_paths("/agents/abc123");
|
|
|
|
|
/// assert!(paths.contains(&"/agents/abc123/shell".to_string()));
|
|
|
|
|
/// assert!(paths.contains(&"/agents/abc123/files".to_string()));
|
|
|
|
|
/// ```
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn registered_paths(&self, base_prefix: &str) -> Vec<String> {
|
|
|
|
|
let base = base_prefix.trim_end_matches('/');
|
|
|
|
|
self.endpoints
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|(components, _)| {
|
|
|
|
|
let sub = components.join("/");
|
|
|
|
|
if sub.is_empty() {
|
|
|
|
|
base.to_owned()
|
|
|
|
|
} else {
|
|
|
|
|
alloc::format!("{base}/{sub}")
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Default for Tree {
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
Self::new()
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-17 17:29:36 -06:00
|
|
|
|
2026-04-20 23:38:02 -06:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Path utilities
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
/// Split a path string into its components.
|
|
|
|
|
///
|
|
|
|
|
/// Leading `/` and empty segments are discarded.
|
|
|
|
|
///
|
|
|
|
|
/// ```text
|
|
|
|
|
/// "/shell/exec" → ["shell", "exec"]
|
|
|
|
|
/// "/shell/" → ["shell"]
|
|
|
|
|
/// "shell" → ["shell"]
|
|
|
|
|
/// "/" → []
|
|
|
|
|
/// ```
|
|
|
|
|
fn split_path(path: &str) -> Vec<String> {
|
|
|
|
|
path.split('/')
|
|
|
|
|
.filter(|s| !s.is_empty())
|
|
|
|
|
.map(String::from)
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns `true` if `prefix` is a prefix of (or equal to) `path`.
|
|
|
|
|
///
|
|
|
|
|
/// Both are slices of path components (already split on `/`).
|
|
|
|
|
///
|
|
|
|
|
/// ```text
|
|
|
|
|
/// prefix = ["shell"] path = ["shell", "exec"] → true
|
|
|
|
|
/// prefix = ["shell", "exec"] path = ["shell", "exec"] → true (exact match)
|
|
|
|
|
/// prefix = ["shell", "exec"] path = ["shell"] → false (prefix longer)
|
|
|
|
|
/// prefix = ["files"] path = ["shell", "exec"] → false (different root)
|
|
|
|
|
/// ```
|
|
|
|
|
fn is_prefix(prefix: &[String], path: &[String]) -> bool {
|
|
|
|
|
if prefix.len() > path.len() {
|
|
|
|
|
return false;
|
2026-03-17 17:29:36 -06:00
|
|
|
}
|
2026-04-20 23:38:02 -06:00
|
|
|
prefix.iter().zip(path.iter()).all(|(a, b)| a == b)
|
|
|
|
|
}
|
2026-03-17 17:29:36 -06:00
|
|
|
|
2026-04-20 23:38:02 -06:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Tests
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use crate::protocol::{RequestType, ResponseStatus, content};
|
|
|
|
|
|
|
|
|
|
// A minimal endpoint that echoes the request data.
|
|
|
|
|
struct Echo;
|
|
|
|
|
impl Endpoint for Echo {
|
|
|
|
|
fn handle(&mut self, req: TreeRequest) -> TreeResponse {
|
|
|
|
|
TreeResponse {
|
|
|
|
|
request_id: req.request_id,
|
|
|
|
|
status: ResponseStatus::Ok,
|
|
|
|
|
content_type: req.content_type,
|
|
|
|
|
data: req.data,
|
2026-03-17 17:29:36 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 23:38:02 -06:00
|
|
|
// A minimal endpoint that always returns a fixed string.
|
|
|
|
|
struct Fixed(&'static str);
|
|
|
|
|
impl Endpoint for Fixed {
|
|
|
|
|
fn handle(&mut self, req: TreeRequest) -> TreeResponse {
|
|
|
|
|
TreeResponse {
|
|
|
|
|
request_id: req.request_id,
|
|
|
|
|
status: ResponseStatus::Ok,
|
|
|
|
|
content_type: content::UTF8_STRING.into(),
|
|
|
|
|
data: self.0.as_bytes().to_vec(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn make_req(id: u64) -> TreeRequest {
|
|
|
|
|
TreeRequest {
|
|
|
|
|
request_id: id,
|
|
|
|
|
request_type: RequestType::Read,
|
|
|
|
|
content_type: content::NONE.into(),
|
|
|
|
|
data: Vec::new(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A single endpoint is matched correctly.
|
|
|
|
|
#[test]
|
|
|
|
|
fn single_endpoint_match() {
|
|
|
|
|
let mut tree = Tree::new();
|
|
|
|
|
tree.register("/shell", Echo);
|
|
|
|
|
|
|
|
|
|
let resp = tree.dispatch(make_req(1), "/shell/exec");
|
|
|
|
|
assert_eq!(resp.status, ResponseStatus::Ok, "expected Ok for /shell/exec");
|
|
|
|
|
assert_eq!(resp.request_id, 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// When two endpoints are registered, the second one is also reachable.
|
|
|
|
|
///
|
|
|
|
|
/// This test specifically catches the old `return None` bug in `get_endpoint`:
|
|
|
|
|
/// the first endpoint (/files) doesn't match /shell/exec, so the tree must
|
|
|
|
|
/// continue to the second entry (/shell).
|
|
|
|
|
#[test]
|
|
|
|
|
fn second_endpoint_match() {
|
|
|
|
|
let mut tree = Tree::new();
|
|
|
|
|
tree.register("/files", Fixed("files"));
|
|
|
|
|
tree.register("/shell", Fixed("shell"));
|
|
|
|
|
|
|
|
|
|
let resp = tree.dispatch(make_req(2), "/shell/exec");
|
|
|
|
|
assert_eq!(resp.status, ResponseStatus::Ok);
|
|
|
|
|
assert_eq!(resp.data, b"shell");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// No matching endpoint returns NoBranchError.
|
|
|
|
|
#[test]
|
|
|
|
|
fn no_match_returns_no_branch_error() {
|
|
|
|
|
let mut tree = Tree::new();
|
|
|
|
|
tree.register("/shell", Echo);
|
|
|
|
|
|
|
|
|
|
let resp = tree.dispatch(make_req(3), "/nonexistent/path");
|
|
|
|
|
assert_eq!(resp.status, ResponseStatus::NoBranchError);
|
|
|
|
|
assert_eq!(resp.request_id, 3);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Longer (more specific) prefix wins over shorter prefix.
|
|
|
|
|
#[test]
|
|
|
|
|
fn longer_prefix_wins() {
|
|
|
|
|
let mut tree = Tree::new();
|
|
|
|
|
tree.register("/shell", Fixed("short"));
|
|
|
|
|
tree.register("/shell/exec", Fixed("long"));
|
|
|
|
|
|
|
|
|
|
let resp = tree.dispatch(make_req(4), "/shell/exec/anything");
|
|
|
|
|
assert_eq!(resp.data, b"long", "longer prefix should win");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A request path that is shorter than the registered prefix does not match.
|
|
|
|
|
#[test]
|
|
|
|
|
fn prefix_does_not_overmatch() {
|
|
|
|
|
let mut tree = Tree::new();
|
|
|
|
|
tree.register("/shell/exec/something", Echo);
|
|
|
|
|
|
|
|
|
|
// /shell/exec is shorter than the registered path — should NOT match
|
|
|
|
|
let resp = tree.dispatch(make_req(5), "/shell/exec");
|
|
|
|
|
assert_eq!(resp.status, ResponseStatus::NoBranchError);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// `registered_paths` returns all prefixes with the base path prepended.
|
|
|
|
|
#[test]
|
|
|
|
|
fn registered_paths_prepends_base() {
|
|
|
|
|
let mut tree = Tree::new();
|
|
|
|
|
tree.register("/shell", Echo);
|
|
|
|
|
tree.register("/files", Echo);
|
|
|
|
|
|
|
|
|
|
let paths = tree.registered_paths("/agents/abc123");
|
|
|
|
|
assert!(paths.contains(&"/agents/abc123/shell".to_string()));
|
|
|
|
|
assert!(paths.contains(&"/agents/abc123/files".to_string()));
|
|
|
|
|
assert_eq!(paths.len(), 2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -----------------------------------------------------------------------
|
|
|
|
|
// Path utility tests
|
|
|
|
|
// -----------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn split_path_leading_slash() {
|
|
|
|
|
assert_eq!(split_path("/shell/exec"), vec!["shell", "exec"]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn split_path_no_leading_slash() {
|
|
|
|
|
assert_eq!(split_path("shell/exec"), vec!["shell", "exec"]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn split_path_trailing_slash() {
|
|
|
|
|
assert_eq!(split_path("/shell/"), vec!["shell"]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn split_path_root() {
|
|
|
|
|
let result: Vec<String> = split_path("/");
|
|
|
|
|
assert!(result.is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn is_prefix_exact_match() {
|
|
|
|
|
let p = split_path("/shell/exec");
|
|
|
|
|
assert!(is_prefix(&p, &p));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn is_prefix_valid() {
|
|
|
|
|
let prefix = split_path("/shell");
|
|
|
|
|
let path = split_path("/shell/exec");
|
|
|
|
|
assert!(is_prefix(&prefix, &path));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn is_prefix_prefix_too_long() {
|
|
|
|
|
let prefix = split_path("/shell/exec");
|
|
|
|
|
let path = split_path("/shell");
|
|
|
|
|
assert!(!is_prefix(&prefix, &path));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn is_prefix_different_root() {
|
|
|
|
|
let prefix = split_path("/files");
|
|
|
|
|
let path = split_path("/shell/exec");
|
|
|
|
|
assert!(!is_prefix(&prefix, &path));
|
|
|
|
|
}
|
2026-03-17 17:29:36 -06:00
|
|
|
}
|