mirror of
https://github.com/Astatin3/unshell.git
synced 2026-06-09 06:47:59 -06:00
feat: complete protocol spec and initial implementation
- Write PROTOCOL.md with full wire format spec and 8 real-world scenario
analyses (reconnect, multi-operator, large files, AV evasion, router crash,
malformed packets, future pivoting)
- Rewrite workspace structure:
- unshell lib: protocol types (PacketHeader, TreeRequest/Response,
HandshakeMessage/Ack), Transport trait, TcpTransport, Tree routing
- ush-router: router binary with per-node threads, NodeRegistry with
longest-prefix path matching, packet relay
- ush-payload: implant binary with reconnect loop, module tree, InfoModule
- ush-cli: operator REPL with rustyline, session management, command parser
- Protocol design: two-part rkyv frame [header][payload]; router reads only
header for routing, payload bytes forwarded opaque
- All code documented with doc comments and examples
- Zero warnings, zero errors across entire workspace
- 32 tests pass (unit tests for tree routing, TCP transport, framing,
command parsing, node registry)
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
//! # REPL Command Parser
|
||||
//!
|
||||
//! Parses lines typed in the operator REPL into structured `Command` values.
|
||||
//!
|
||||
//! ## Supported commands
|
||||
//!
|
||||
//! | Command | Description |
|
||||
//! |---|---|
|
||||
//! | `list` | List all connected nodes |
|
||||
//! | `use <path>` | Set the current working path |
|
||||
//! | `ls [path]` | List procedures at `path` (or current path) |
|
||||
//! | `call <path> [data]` | Call a procedure at `path` |
|
||||
//! | `read <path>` | Read a value at `path` |
|
||||
//! | `write <path> <data>` | Write a value to `path` |
|
||||
//! | `background` | Background the current session |
|
||||
//! | `sessions` | List backgrounded sessions |
|
||||
//! | `exit` | Disconnect and quit |
|
||||
//! | `help` | Print this help |
|
||||
|
||||
/// A parsed REPL command.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Command {
|
||||
/// `list` — list all connected nodes via `/router/nodes`.
|
||||
List,
|
||||
/// `use <path>` — set the current working path.
|
||||
Use(String),
|
||||
/// `ls [path]` — `GetProcedures` at the given or current path.
|
||||
Ls(Option<String>),
|
||||
/// `call <path> [data]` — `CallProcedure` at `path` with optional `data`.
|
||||
Call { path: String, data: Option<String> },
|
||||
/// `read <path>` — `Read` at `path`.
|
||||
Read(String),
|
||||
/// `write <path> <data>` — `Write` at `path` with `data`.
|
||||
Write { path: String, data: String },
|
||||
/// `background` — push current session to background list.
|
||||
Background,
|
||||
/// `sessions` — list backgrounded sessions.
|
||||
Sessions,
|
||||
/// `exit` — disconnect and quit.
|
||||
Exit,
|
||||
/// `help` — print command help.
|
||||
Help,
|
||||
}
|
||||
|
||||
/// Parse a line of input into a `Command`.
|
||||
///
|
||||
/// Returns `None` if the line is empty or a comment (`#`).
|
||||
/// Returns `Err` if the line cannot be parsed as a valid command.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use ush_cli::commands::{parse, Command};
|
||||
///
|
||||
/// assert_eq!(parse("list").unwrap(), Some(Command::List));
|
||||
/// assert_eq!(parse("use /agents/abc123").unwrap(), Some(Command::Use("/agents/abc123".into())));
|
||||
/// assert_eq!(parse("").unwrap(), None);
|
||||
/// assert_eq!(parse(" # comment").unwrap(), None);
|
||||
/// ```
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error string if the command name is unrecognised or the
|
||||
/// arguments are malformed.
|
||||
pub fn parse(line: &str) -> Result<Option<Command>, String> {
|
||||
let trimmed = line.trim();
|
||||
|
||||
// Empty lines and comments
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut parts = trimmed.splitn(3, ' ');
|
||||
let cmd = parts.next().unwrap_or("");
|
||||
let arg1 = parts.next().map(str::trim);
|
||||
let arg2 = parts.next().map(str::trim);
|
||||
|
||||
match cmd {
|
||||
"list" => Ok(Some(Command::List)),
|
||||
"use" => {
|
||||
let path = arg1.ok_or("usage: use <path>")?;
|
||||
Ok(Some(Command::Use(path.to_owned())))
|
||||
}
|
||||
"ls" => Ok(Some(Command::Ls(arg1.map(str::to_owned)))),
|
||||
"call" => {
|
||||
let path = arg1.ok_or("usage: call <path> [data]")?;
|
||||
Ok(Some(Command::Call {
|
||||
path: path.to_owned(),
|
||||
data: arg2.map(str::to_owned),
|
||||
}))
|
||||
}
|
||||
"read" => {
|
||||
let path = arg1.ok_or("usage: read <path>")?;
|
||||
Ok(Some(Command::Read(path.to_owned())))
|
||||
}
|
||||
"write" => {
|
||||
let path = arg1.ok_or("usage: write <path> <data>")?;
|
||||
let data = arg2.ok_or("usage: write <path> <data>")?;
|
||||
Ok(Some(Command::Write {
|
||||
path: path.to_owned(),
|
||||
data: data.to_owned(),
|
||||
}))
|
||||
}
|
||||
"background" | "bg" => Ok(Some(Command::Background)),
|
||||
"sessions" => Ok(Some(Command::Sessions)),
|
||||
"exit" | "quit" | "q" => Ok(Some(Command::Exit)),
|
||||
"help" | "?" => Ok(Some(Command::Help)),
|
||||
other => Err(format!("unknown command: {other}. Type 'help' for a list.")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Print the help text for all available commands.
|
||||
pub fn print_help() {
|
||||
println!("Available commands:");
|
||||
println!(" list List all connected nodes");
|
||||
println!(" use <path> Set working path (e.g., use agents/abc123)");
|
||||
println!(" ls [path] List available procedures");
|
||||
println!(" call <path> [data] Call a procedure");
|
||||
println!(" read <path> Read a value");
|
||||
println!(" write <path> <data> Write a value");
|
||||
println!(" background Background current session");
|
||||
println!(" sessions List backgrounded sessions");
|
||||
println!(" exit Disconnect and quit");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_empty() {
|
||||
assert_eq!(parse("").unwrap(), None);
|
||||
assert_eq!(parse(" ").unwrap(), None);
|
||||
assert_eq!(parse("# comment").unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_list() {
|
||||
assert_eq!(parse("list").unwrap(), Some(Command::List));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_use() {
|
||||
assert_eq!(
|
||||
parse("use /agents/abc123").unwrap(),
|
||||
Some(Command::Use("/agents/abc123".into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ls_no_arg() {
|
||||
assert_eq!(parse("ls").unwrap(), Some(Command::Ls(None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ls_with_arg() {
|
||||
assert_eq!(
|
||||
parse("ls shell").unwrap(),
|
||||
Some(Command::Ls(Some("shell".into())))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_call_with_data() {
|
||||
assert_eq!(
|
||||
parse("call shell/exec ls -la").unwrap(),
|
||||
Some(Command::Call {
|
||||
path: "shell/exec".into(),
|
||||
data: Some("ls -la".into()),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_exit_aliases() {
|
||||
assert_eq!(parse("exit").unwrap(), Some(Command::Exit));
|
||||
assert_eq!(parse("quit").unwrap(), Some(Command::Exit));
|
||||
assert_eq!(parse("q").unwrap(), Some(Command::Exit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_unknown_command() {
|
||||
assert!(parse("foobar").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//! # ush-cli — UnShell Operator REPL
|
||||
//!
|
||||
//! The operator CLI connects to the router as a first-class node and provides
|
||||
//! an interactive shell for issuing commands to connected payload nodes.
|
||||
//!
|
||||
//! ## Usage
|
||||
//!
|
||||
//! ```text
|
||||
//! ush-cli --router 127.0.0.1:9000
|
||||
//! ```
|
||||
//!
|
||||
//! ## REPL commands
|
||||
//!
|
||||
//! ```text
|
||||
//! unshell> list # list all connected nodes
|
||||
//! unshell> use agents/abc123 # set working path prefix
|
||||
//! unshell [agents/abc123]> ls # GetProcedures at current path
|
||||
//! unshell [agents/abc123]> call shell/exec "ls -la"
|
||||
//! unshell [agents/abc123]> read files/passwd
|
||||
//! unshell [agents/abc123]> background # detach, keep in session list
|
||||
//! unshell> sessions # list background sessions
|
||||
//! unshell> exit # disconnect and quit
|
||||
//! ```
|
||||
|
||||
mod commands;
|
||||
mod repl;
|
||||
mod session;
|
||||
|
||||
fn main() {
|
||||
// TODO: parse --router argument
|
||||
let router_addr = "127.0.0.1:9000";
|
||||
repl::run(router_addr).expect("repl failed");
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
//! # REPL Core
|
||||
//!
|
||||
//! The main interactive loop for the operator CLI.
|
||||
//!
|
||||
//! ## Flow
|
||||
//!
|
||||
//! ```text
|
||||
//! run()
|
||||
//! ↓
|
||||
//! connect to router → handshake → register as operator node
|
||||
//! ↓
|
||||
//! start recv thread (router → operator messages)
|
||||
//! ↓
|
||||
//! main thread: readline loop
|
||||
//! parse command
|
||||
//! execute (may send TreeRequest over transport)
|
||||
//! print response
|
||||
//! ```
|
||||
//!
|
||||
//! ## Threading model
|
||||
//!
|
||||
//! The transport is shared between:
|
||||
//! - The main thread (sends requests, prints responses).
|
||||
//! - A background recv thread (receives unsolicited messages from the router,
|
||||
//! e.g., node-connected notifications — future feature).
|
||||
//!
|
||||
//! In v1, the main thread does both send and receive synchronously (blocking
|
||||
//! recv after each send). The recv thread is reserved for future async notifications.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use rustyline::error::ReadlineError;
|
||||
use rustyline::DefaultEditor;
|
||||
|
||||
use unshell::protocol::{
|
||||
content, HandshakeAck, HandshakeMessage, NodeType,
|
||||
PacketHeader, PacketType, RequestType, TreeRequest,
|
||||
};
|
||||
use unshell::transport::tcp::TcpTransport;
|
||||
use unshell::transport::Transport;
|
||||
|
||||
use crate::commands::{self, Command};
|
||||
use crate::session::Session;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request ID counter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Monotonically increasing request ID generator.
|
||||
///
|
||||
/// Generates unique IDs so the operator can correlate responses to requests
|
||||
/// in the future when multiple requests are in-flight concurrently.
|
||||
static REQUEST_COUNTER: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
fn next_request_id() -> u64 {
|
||||
REQUEST_COUNTER.fetch_add(1, Ordering::SeqCst)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Start the operator REPL, connecting to `router_addr`.
|
||||
///
|
||||
/// Blocks until the user types `exit` or the connection is lost.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the connection or handshake fails.
|
||||
pub fn run(router_addr: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("UnShell operator console");
|
||||
println!("Connecting to {}...", router_addr);
|
||||
|
||||
let mut transport = TcpTransport::connect(router_addr)?;
|
||||
let session_id = format!("sess{}", std::process::id());
|
||||
let base_path = format!("/operator/{session_id}");
|
||||
|
||||
// Handshake
|
||||
let handshake = HandshakeMessage {
|
||||
node_id: session_id.clone(),
|
||||
node_type: NodeType::Operator,
|
||||
registered_paths: vec![base_path.clone()],
|
||||
platform: "operator".to_owned(),
|
||||
};
|
||||
let handshake_payload = rkyv::to_bytes::<rkyv::rancor::Error>(&handshake)
|
||||
.map_err(|e| format!("failed to serialise handshake: {e}"))?;
|
||||
let handshake_header = PacketHeader {
|
||||
dst_path: "/router".to_owned(),
|
||||
src_path: base_path.clone(),
|
||||
packet_type: PacketType::Handshake,
|
||||
};
|
||||
transport.send(&handshake_header, &handshake_payload)?;
|
||||
|
||||
let (_, ack_payload) = transport.recv()?;
|
||||
let ack: HandshakeAck =
|
||||
rkyv::from_bytes::<HandshakeAck, rkyv::rancor::Error>(&ack_payload)
|
||||
.map_err(|e| format!("failed to deserialise ack: {e}"))?;
|
||||
|
||||
if !ack.accepted {
|
||||
return Err(format!(
|
||||
"router rejected: {}",
|
||||
ack.rejection_reason.unwrap_or_default()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
println!("Connected. Type 'help' for commands.");
|
||||
|
||||
// Wrap transport in a Mutex for shared access
|
||||
let transport = Arc::new(Mutex::new(transport));
|
||||
|
||||
// REPL state
|
||||
let mut current_session = Session::new("default", "/");
|
||||
let mut background_sessions: Vec<Session> = Vec::new();
|
||||
|
||||
// Readline editor with history
|
||||
let mut rl = DefaultEditor::new()?;
|
||||
|
||||
loop {
|
||||
let prompt = if current_session.current_path == "/" {
|
||||
"unshell> ".to_owned()
|
||||
} else {
|
||||
let short = current_session
|
||||
.current_path
|
||||
.trim_start_matches("/agents/")
|
||||
.trim_start_matches("/operator/");
|
||||
format!("unshell [{short}]> ")
|
||||
};
|
||||
|
||||
let readline = rl.readline(&prompt);
|
||||
match readline {
|
||||
Ok(line) => {
|
||||
rl.add_history_entry(line.as_str())
|
||||
.unwrap_or_default();
|
||||
|
||||
match commands::parse(&line) {
|
||||
Ok(None) => {} // empty / comment
|
||||
Ok(Some(cmd)) => {
|
||||
if !handle_command(
|
||||
cmd,
|
||||
&mut current_session,
|
||||
&mut background_sessions,
|
||||
&base_path,
|
||||
&transport,
|
||||
) {
|
||||
break; // exit command
|
||||
}
|
||||
}
|
||||
Err(e) => println!("error: {e}"),
|
||||
}
|
||||
}
|
||||
Err(ReadlineError::Interrupted | ReadlineError::Eof) => {
|
||||
println!("Disconnecting...");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("readline error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("Bye.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Handle one parsed command.
|
||||
///
|
||||
/// Returns `false` if the REPL should exit, `true` to continue.
|
||||
fn handle_command(
|
||||
cmd: Command,
|
||||
current_session: &mut Session,
|
||||
background_sessions: &mut Vec<Session>,
|
||||
base_path: &str,
|
||||
transport: &Arc<Mutex<TcpTransport>>,
|
||||
) -> bool {
|
||||
match cmd {
|
||||
Command::Exit => return false,
|
||||
|
||||
Command::Help => commands::print_help(),
|
||||
|
||||
Command::Use(path) => {
|
||||
// Normalise: if no leading slash, prepend /agents/
|
||||
let resolved = if path.starts_with('/') {
|
||||
path
|
||||
} else {
|
||||
format!("/agents/{path}")
|
||||
};
|
||||
current_session.current_path = resolved;
|
||||
println!("current path: {}", current_session.current_path);
|
||||
}
|
||||
|
||||
Command::List => {
|
||||
// Send GetProcedures to /router/nodes
|
||||
send_request_and_print(
|
||||
"/router/nodes",
|
||||
RequestType::GetProcedures,
|
||||
content::NONE,
|
||||
None,
|
||||
base_path,
|
||||
transport,
|
||||
);
|
||||
}
|
||||
|
||||
Command::Ls(sub_path) => {
|
||||
let path = sub_path
|
||||
.as_deref()
|
||||
.map(|p| current_session.resolve(p))
|
||||
.unwrap_or_else(|| current_session.current_path.clone());
|
||||
send_request_and_print(
|
||||
&path,
|
||||
RequestType::GetProcedures,
|
||||
content::NONE,
|
||||
None,
|
||||
base_path,
|
||||
transport,
|
||||
);
|
||||
}
|
||||
|
||||
Command::Read(sub_path) => {
|
||||
let path = current_session.resolve(&sub_path);
|
||||
send_request_and_print(
|
||||
&path,
|
||||
RequestType::Read,
|
||||
content::NONE,
|
||||
None,
|
||||
base_path,
|
||||
transport,
|
||||
);
|
||||
}
|
||||
|
||||
Command::Call { path, data } => {
|
||||
let full_path = current_session.resolve(&path);
|
||||
send_request_and_print(
|
||||
&full_path,
|
||||
RequestType::CallProcedure,
|
||||
content::UTF8_STRING,
|
||||
data.as_deref(),
|
||||
base_path,
|
||||
transport,
|
||||
);
|
||||
}
|
||||
|
||||
Command::Write { path, data } => {
|
||||
let full_path = current_session.resolve(&path);
|
||||
send_request_and_print(
|
||||
&full_path,
|
||||
RequestType::Write,
|
||||
content::UTF8_STRING,
|
||||
Some(&data),
|
||||
base_path,
|
||||
transport,
|
||||
);
|
||||
}
|
||||
|
||||
Command::Background => {
|
||||
let mut session = current_session.clone();
|
||||
session.active = false;
|
||||
background_sessions.push(session);
|
||||
current_session.current_path = "/".to_owned();
|
||||
println!("session backgrounded. Type 'sessions' to list.");
|
||||
}
|
||||
|
||||
Command::Sessions => {
|
||||
if background_sessions.is_empty() {
|
||||
println!("no background sessions");
|
||||
} else {
|
||||
for (i, sess) in background_sessions.iter().enumerate() {
|
||||
println!(" [{i}] {} ({})", sess.name, sess.current_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Send a `TreeRequest` and print the response.
|
||||
fn send_request_and_print(
|
||||
dst_path: &str,
|
||||
request_type: RequestType,
|
||||
content_type: &str,
|
||||
data: Option<&str>,
|
||||
src_path: &str,
|
||||
transport: &Arc<Mutex<TcpTransport>>,
|
||||
) {
|
||||
let request = TreeRequest {
|
||||
request_id: next_request_id(),
|
||||
request_type,
|
||||
content_type: content_type.to_owned(),
|
||||
data: data.map(|s| s.as_bytes().to_vec()).unwrap_or_default(),
|
||||
};
|
||||
|
||||
let Ok(payload) = rkyv::to_bytes::<rkyv::rancor::Error>(&request) else {
|
||||
eprintln!("error: failed to serialise request");
|
||||
return;
|
||||
};
|
||||
|
||||
let header = PacketHeader {
|
||||
dst_path: dst_path.to_owned(),
|
||||
src_path: src_path.to_owned(),
|
||||
packet_type: PacketType::Request,
|
||||
};
|
||||
|
||||
let mut t = transport.lock().expect("transport lock poisoned");
|
||||
|
||||
if let Err(e) = t.send(&header, &payload) {
|
||||
eprintln!("send error: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
match t.recv() {
|
||||
Ok((_, resp_payload)) => {
|
||||
match rkyv::from_bytes::<unshell::protocol::TreeResponse, rkyv::rancor::Error>(
|
||||
&resp_payload,
|
||||
) {
|
||||
Ok(resp) => {
|
||||
if resp.data.is_empty() {
|
||||
println!("[{:?}]", resp.status);
|
||||
} else if let Ok(text) = std::str::from_utf8(&resp.data) {
|
||||
println!("{text}");
|
||||
} else {
|
||||
println!("[{} bytes, content-type: {}]", resp.data.len(), resp.content_type);
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("error: failed to deserialise response: {e}"),
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("recv error: {e}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! # Session Management
|
||||
//!
|
||||
//! A `Session` represents an active connection context to a specific node path.
|
||||
//!
|
||||
//! The operator can have multiple named sessions open simultaneously. Each session
|
||||
//! has a "current path" (e.g., `/agents/abc123`) that prefixes commands.
|
||||
//! Sessions can be backgrounded and switched between without disconnecting.
|
||||
//!
|
||||
//! ## Session lifecycle
|
||||
//!
|
||||
//! ```text
|
||||
//! connect → handshake → session created
|
||||
//! ↓
|
||||
//! use agents/abc123 ← sets current_path
|
||||
//! ↓
|
||||
//! call shell/exec ← sends to /agents/abc123/shell/exec
|
||||
//! ↓
|
||||
//! background ← pushed to session list, detached
|
||||
//! ↓
|
||||
//! sessions ← lists all active sessions
|
||||
//! ↓
|
||||
//! use <session_id> ← reattaches
|
||||
//! ```
|
||||
|
||||
/// A named, backgroundable session context.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Session {
|
||||
/// Human-readable name (e.g., "abc123" or "session-1").
|
||||
pub name: String,
|
||||
/// The current working path (e.g., `/agents/abc123`).
|
||||
pub current_path: String,
|
||||
/// Whether this session is in the foreground.
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Create a new session at the given path.
|
||||
#[must_use]
|
||||
pub fn new(name: impl Into<String>, path: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
current_path: path.into(),
|
||||
active: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the full path for a sub-path command.
|
||||
///
|
||||
/// If `sub_path` is absolute (starts with `/`), return it unchanged.
|
||||
/// Otherwise, append it to `current_path`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// let sess = Session::new("abc123", "/agents/abc123");
|
||||
/// assert_eq!(sess.resolve("shell/exec"), "/agents/abc123/shell/exec");
|
||||
/// assert_eq!(sess.resolve("/router/nodes"), "/router/nodes");
|
||||
/// ```
|
||||
#[must_use]
|
||||
pub fn resolve(&self, sub_path: &str) -> String {
|
||||
if sub_path.starts_with('/') {
|
||||
sub_path.to_owned()
|
||||
} else {
|
||||
format!("{}/{sub_path}", self.current_path.trim_end_matches('/'))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user