use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, RwLock};
use rns_core::destination::destination_hash;
use rns_crypto::identity::Identity;
use rns_net::{Destination, IdentityHash, RnsNode};
use tokio_util::sync::CancellationToken;
use tracing::{debug, info, warn};
use crate::micron::MicronBuilder;
use crate::types::{NodeConfig, NomadError};
#[derive(Clone, Debug, Default)]
pub struct RequestData {
fields: HashMap<String, String>,
vars: HashMap<String, String>,
}
impl RequestData {
pub fn from_bytes(data: &[u8]) -> Self {
if data.is_empty() {
return Self::default();
}
let map: HashMap<String, String> = match rmp_serde::from_slice(data) {
Ok(m) => m,
Err(_) => return Self::default(),
};
let mut fields = HashMap::new();
let mut vars = HashMap::new();
for (k, v) in map {
if let Some(name) = k.strip_prefix("field_") {
fields.insert(name.to_string(), v);
} else if let Some(name) = k.strip_prefix("var_") {
vars.insert(name.to_string(), v);
}
}
Self { fields, vars }
}
pub fn field(&self, name: &str) -> Option<&str> {
self.fields.get(name).map(|s| s.as_str())
}
pub fn var(&self, name: &str) -> Option<&str> {
self.vars.get(name).map(|s| s.as_str())
}
pub fn fields(&self) -> &HashMap<String, String> {
&self.fields
}
pub fn vars(&self) -> &HashMap<String, String> {
&self.vars
}
pub fn is_empty(&self) -> bool {
self.fields.is_empty() && self.vars.is_empty()
}
pub fn to_env_map(&self) -> HashMap<String, String> {
let mut map = HashMap::new();
for (k, v) in &self.fields {
map.insert(format!("field_{k}"), v.clone());
}
for (k, v) in &self.vars {
map.insert(format!("var_{k}"), v.clone());
}
map
}
}
#[derive(Clone, Debug)]
pub struct FileEntry {
pub content: Vec<u8>,
pub name: String,
}
#[derive(Clone, Debug)]
pub struct FileCache {
inner: Arc<RwLock<HashMap<String, FileEntry>>>,
}
impl FileCache {
pub fn new() -> Self {
Self {
inner: Arc::new(RwLock::new(HashMap::new())),
}
}
pub fn set(&self, path: &str, entry: FileEntry) {
self.inner
.write()
.unwrap_or_else(|e| e.into_inner())
.insert(path.to_string(), entry);
}
pub fn get(&self, path: &str) -> Option<FileEntry> {
self.inner
.read()
.unwrap_or_else(|e| e.into_inner())
.get(path)
.cloned()
}
pub fn remove(&self, path: &str) {
self.inner
.write()
.unwrap_or_else(|e| e.into_inner())
.remove(path);
}
pub fn paths(&self) -> Vec<String> {
self.inner
.read()
.unwrap_or_else(|e| e.into_inner())
.keys()
.cloned()
.collect()
}
}
impl Default for FileCache {
fn default() -> Self {
Self::new()
}
}
pub const MAX_RESPONSE_BYTES: usize = 350;
pub const MAX_PAGES_PER_FILE: usize = 200;
pub const CHUNK_TARGET_BYTES: usize = 220;
#[derive(Clone, Debug)]
pub struct PageCache {
inner: Arc<RwLock<HashMap<String, Vec<u8>>>>,
}
impl PageCache {
pub fn new() -> Self {
Self {
inner: Arc::new(RwLock::new(HashMap::new())),
}
}
pub fn set(&self, path: &str, content: Vec<u8>) {
self.inner
.write()
.unwrap_or_else(|e| e.into_inner())
.insert(path.to_string(), content);
}
pub fn get(&self, path: &str) -> Option<Vec<u8>> {
self.inner
.read()
.unwrap_or_else(|e| e.into_inner())
.get(path)
.cloned()
}
pub fn remove(&self, path: &str) {
self.inner
.write()
.unwrap_or_else(|e| e.into_inner())
.remove(path);
}
pub fn paths(&self) -> Vec<String> {
self.inner
.read()
.unwrap_or_else(|e| e.into_inner())
.keys()
.cloned()
.collect()
}
}
impl Default for PageCache {
fn default() -> Self {
Self::new()
}
}
fn build_404_page(path: &str, nomad_address: &str) -> Vec<u8> {
let mut page = MicronBuilder::new();
page.cache_directive(0);
page.heading(1, "404 — Page Not Found");
page.divider();
let escaped = MicronBuilder::escape(path);
page.text_raw_line(&format!(
"The page `{escaped}` does not exist on this node."
));
page.blank_line();
page.link("Back to index", &format!("{nomad_address}:/page/index.mu"));
page.build().into_bytes()
}
pub fn build_default_index(_nomad_address: &str) -> Vec<u8> {
b">Default Home Page\n\nThis node is serving pages, but the home page file (index.mu) was not found in the page storage directory. This is an auto-generated placeholder.\n\nIf you are the node operator, you can define your own home page by creating a file named `index.mu` in the page storage directory.\n".to_vec()
}
pub fn paginate_path(base_path: &str, page_num: usize) -> String {
if page_num <= 1 {
return base_path.to_string();
}
let stem = base_path.strip_suffix(".mu").unwrap_or(base_path);
format!("{stem}/{page_num}.mu")
}
pub fn split_into_chunks(content: &str, target_bytes: usize) -> Vec<String> {
let mut chunks = Vec::new();
let mut current = String::new();
for line in content.lines() {
if current.len() + line.len() + 1 > target_bytes && !current.is_empty() {
chunks.push(std::mem::take(&mut current));
}
if !current.is_empty() {
current.push('\n');
}
current.push_str(line);
}
if !current.is_empty() {
chunks.push(current);
}
if chunks.is_empty() {
chunks.push(String::new());
}
chunks
}
pub fn base_path_from_paginated(path: &str) -> (String, Option<usize>) {
let stem = path.strip_suffix(".mu").unwrap_or(path);
if let Some(idx) = stem.rfind('/') {
let base_stem = &stem[..idx];
let page_str = &stem[idx + 1..];
if let Ok(page_num) = page_str.parse::<usize>() {
if (2..=MAX_PAGES_PER_FILE).contains(&page_num) {
return (format!("{base_stem}.mu"), Some(page_num));
}
}
}
(path.to_string(), None)
}
pub fn build_paginated_page(
chunk: &str,
page_num: usize,
total_pages: usize,
base_path: &str,
nomad_address: &str,
) -> Vec<u8> {
let mut page = MicronBuilder::new();
page.cache_directive(0);
page.text_raw_line(chunk);
if total_pages > 1 {
page.blank_line();
page.divider();
page.blank_line();
if page_num > 1 {
let prev_link = paginate_path(base_path, page_num - 1);
page.link("<< Previous page", &format!("{nomad_address}:{prev_link}"));
} else {
page.text_raw_line(" ");
}
page.text_raw_line(&format!(" Page {page_num} of {total_pages}"));
if page_num < total_pages {
let next_link = paginate_path(base_path, page_num + 1);
page.link("Next page >>", &format!("{nomad_address}:{next_link}"));
} else {
page.text_raw_line(" ");
}
}
page.build().into_bytes()
}
pub struct NomadNode {
dest_hash: [u8; 16],
identity_hash: [u8; 16],
identity_prv: [u8; 64],
node_name: String,
announce_interval_secs: u64,
page_cache: PageCache,
file_cache: FileCache,
pages_dir: Option<PathBuf>,
on_announce: Option<Arc<dyn Fn() + Send + Sync>>,
}
impl NomadNode {
pub fn new(
node: Arc<RnsNode>,
config: NodeConfig,
paths: &[&str],
file_paths: &[&str],
pages_dir: Option<PathBuf>,
) -> Result<Self, NomadError> {
let identity = Identity::from_private_key(&config.identity_prv);
let identity_hash_bytes = *identity.hash();
let identity_hash = IdentityHash(identity_hash_bytes);
let dest_hash = destination_hash("nomadnetwork", &["node"], Some(&identity_hash_bytes));
debug!(
"NomadNode: identity={} dest={}",
hex::encode(identity_hash_bytes),
hex::encode(dest_hash)
);
let sig_prv_bytes: [u8; 32] = config.identity_prv[32..64]
.try_into()
.map_err(|_| NomadError::DestinationRegistrationFailed)?;
let derived_pub = identity
.get_public_key()
.ok_or(NomadError::DestinationRegistrationFailed)?;
let sig_pub_bytes: [u8; 32] = derived_pub[32..64]
.try_into()
.map_err(|_| NomadError::DestinationRegistrationFailed)?;
if config.identity_pub != sig_pub_bytes {
return Err(NomadError::IdentityKeyMismatch {
expected_sig_pub_hex: hex::encode(sig_pub_bytes),
provided_sig_pub_hex: hex::encode(config.identity_pub),
});
}
let inbound_dest = Destination::single_in("nomadnetwork", &["node"], identity_hash);
node.register_destination_with_proof(&inbound_dest, Some(config.identity_prv))
.map_err(|_| NomadError::DestinationRegistrationFailed)?;
node.register_link_destination(dest_hash, sig_prv_bytes, sig_pub_bytes, 0)
.map_err(|_| NomadError::DestinationRegistrationFailed)?;
let page_cache = PageCache::new();
let nomad_address = hex::encode(dest_hash);
let pages_dir_clone = pages_dir.clone();
for path in paths {
let cache = page_cache.clone();
let path_owned = path.to_string();
let nomad_addr = nomad_address.clone();
let pdir = pages_dir_clone.clone();
node.register_request_handler(
path,
None,
move |link_id, req_path, data, remote_identity| {
info!(
"NomadNode: request on link {:02x?} for path={} ({} bytes data)",
&link_id[..4],
req_path,
data.len()
);
let page = serve_page_inner(
&path_owned,
data,
link_id,
remote_identity,
&cache,
&nomad_addr,
pdir.as_deref(),
);
Some(page)
},
)
.map_err(|_| NomadError::DestinationRegistrationFailed)?;
info!("NomadNode: registered handler for {}", path);
}
{
let page_bytes = {
let mut p = MicronBuilder::new();
p.cache_directive(0);
p.heading(1, "Test Page");
p.divider();
p.text("If you can read this, the built-in response path works!");
p.blank_line();
p.link("Back to index", &format!("{nomad_address}:/page/index.mu"));
p.build().into_bytes()
};
node.register_request_handler(
"/page/test.mu",
None,
move |link_id, req_path, _data, _remote_identity| {
info!(
"NomadNode: TEST handler on link {:02x?} for path={} — returning static page ({} bytes)",
&link_id[..4],
req_path,
page_bytes.len()
);
Some(page_bytes.clone())
},
)
.map_err(|_| NomadError::DestinationRegistrationFailed)?;
info!("NomadNode: registered TEST handler for /page/test.mu");
}
let file_cache = FileCache::new();
for fpath in file_paths {
let fc = file_cache.clone();
let fpath_owned = fpath.to_string();
let file_name_from_path = fpath
.trim_start_matches("/file/")
.rsplit('/')
.next()
.unwrap_or("")
.to_string();
node.register_request_handler(
fpath,
None,
move |link_id, req_path, _data, _remote_identity| {
info!(
"NomadNode: file request on link {:02x?} for path={}",
&link_id[..4],
req_path
);
match fc.get(&fpath_owned) {
Some(entry) => {
info!(
"NomadNode: serving file {} ({} bytes)",
entry.name,
entry.content.len()
);
let name = if entry.name.is_empty() {
file_name_from_path.clone()
} else {
entry.name.clone()
};
encode_file_response(&name, &entry.content)
}
None => {
warn!("NomadNode: file cache miss for {}", fpath_owned);
None
}
}
},
)
.map_err(|_| NomadError::DestinationRegistrationFailed)?;
info!("NomadNode: registered FILE handler for {}", fpath);
}
info!(
"NomadNode initialized: dest={} name=\"{}\" ({} pages + test, {} files)",
hex::encode(dest_hash),
config.node_name,
paths.len(),
file_paths.len()
);
Ok(Self {
dest_hash,
identity_hash: identity_hash_bytes,
identity_prv: config.identity_prv,
node_name: config.node_name,
announce_interval_secs: config.announce_interval_secs,
page_cache,
file_cache,
pages_dir,
on_announce: None,
})
}
pub fn set_on_announce(&mut self, cb: Option<Arc<dyn Fn() + Send + Sync>>) {
self.on_announce = cb;
}
pub fn dest_hash(&self) -> &[u8; 16] {
&self.dest_hash
}
pub fn identity_hash(&self) -> &[u8; 16] {
&self.identity_hash
}
pub fn node_name(&self) -> &str {
&self.node_name
}
pub fn page_cache(&self) -> PageCache {
self.page_cache.clone()
}
pub fn file_cache(&self) -> FileCache {
self.file_cache.clone()
}
pub fn start_announcing(
&self,
node: Arc<RnsNode>,
cancel: CancellationToken,
) -> Result<(), NomadError> {
let identity = Identity::from_private_key(&self.identity_prv);
let dest =
Destination::single_in("nomadnetwork", &["node"], IdentityHash(self.identity_hash));
let node_name = self.node_name.clone();
let dest_hash = self.dest_hash;
let interval_secs = self.announce_interval_secs;
let on_announce = self.on_announce.clone();
tokio::spawn(async move {
let initial_delay = tokio::time::Duration::from_secs(2);
if tokio::time::timeout(initial_delay, cancel.cancelled())
.await
.is_ok()
{
return;
}
loop {
let app_data = node_name.as_bytes();
match node.announce(&dest, &identity, Some(app_data)) {
Ok(()) => {
info!(
"NomadNode announced: dest={} name=\"{}\"",
hex::encode(dest_hash),
node_name
);
if let Some(ref cb) = on_announce {
let cb = cb.clone();
tokio::spawn(async move {
let _ =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
cb();
}));
});
}
}
Err(e) => {
warn!("NomadNode announce failed: {:?}", e);
}
}
let interval = tokio::time::Duration::from_secs(interval_secs);
if tokio::time::timeout(interval, cancel.cancelled())
.await
.is_ok()
{
break;
}
}
});
Ok(())
}
pub fn serve_page(
&self,
path: &str,
data: &[u8],
link_id: [u8; 16],
remote_identity: Option<&([u8; 16], [u8; 64])>,
) -> Vec<u8> {
serve_page_inner(
path,
data,
link_id,
remote_identity,
&self.page_cache,
&hex::encode(self.dest_hash),
self.pages_dir.as_deref(),
)
}
pub fn pages_dir(&self) -> Option<&Path> {
self.pages_dir.as_deref()
}
}
fn serve_page_inner(
path: &str,
data: &[u8],
link_id: [u8; 16],
remote_identity: Option<&([u8; 16], [u8; 64])>,
page_cache: &PageCache,
nomad_address: &str,
pages_dir: Option<&Path>,
) -> Vec<u8> {
if let Some(dir) = pages_dir {
let stripped = path.trim_start_matches('/');
let file_path = dir.join(stripped);
if let Ok(canonical) = file_path.canonicalize() {
if let Ok(dir_canonical) = dir.canonicalize() {
if !canonical.starts_with(&dir_canonical) {
warn!("NomadNode: path traversal blocked for {}", path);
return build_404_page(path, nomad_address);
}
}
if let Ok(metadata) = canonical.metadata() {
if !metadata.is_dir() && is_executable(&metadata) {
let remote_id_bytes = remote_identity.map(|(hash, _pub)| hash.as_slice());
return run_cgi(&canonical, data, &link_id, remote_id_bytes, nomad_address);
}
}
}
}
let is_index = path == "/page/index.mu";
page_cache.get(path).unwrap_or_else(|| {
if is_index {
build_default_index(nomad_address)
} else {
build_404_page(path, nomad_address)
}
})
}
fn encode_file_response(file_name: &str, content: &[u8]) -> Option<Vec<u8>> {
use rns_core::msgpack::{self, Value};
let safe_name = file_name
.rsplit('/')
.next()
.unwrap_or(file_name)
.to_string();
let arr = Value::Array(vec![Value::Str(safe_name), Value::Bin(content.to_vec())]);
Some(msgpack::pack(&arr))
}
pub(crate) fn decode_file_response(data: &[u8]) -> Option<(String, Vec<u8>)> {
use rns_core::msgpack::{self, Value};
let val = msgpack::unpack_exact(data).ok()?;
match val {
Value::Array(arr) if arr.len() == 2 => {
let name = match &arr[0] {
Value::Str(s) => s.clone(),
Value::Bin(b) => String::from_utf8_lossy(b).to_string(),
_ => return None,
};
let content = match &arr[1] {
Value::Bin(b) => b.clone(),
Value::Str(s) => s.as_bytes().to_vec(),
_ => return None,
};
Some((name, content))
}
_ => None,
}
}
fn run_cgi(
file_path: &Path,
data: &[u8],
link_id: &[u8],
remote_identity: Option<&[u8]>,
nomad_address: &str,
) -> Vec<u8> {
let request_data = RequestData::from_bytes(data);
let mut env = request_data.to_env_map();
if let Ok(path_var) = std::env::var("PATH") {
env.insert("PATH".to_string(), path_var);
}
if !link_id.is_empty() {
env.insert("link_id".to_string(), hex::encode(link_id));
}
if let Some(identity) = remote_identity {
if identity.len() >= 32 {
env.insert("remote_identity".to_string(), hex::encode(&identity[..32]));
}
}
match Command::new(file_path).env_clear().envs(&env).output() {
Ok(output) => output.stdout,
Err(e) => {
warn!(
"NomadNode: CGI execution failed for {}: {e}",
file_path.display()
);
build_404_page(
&format!("(cgi error: {})", file_path.display()),
nomad_address,
)
}
}
}
pub const DEFAULT_NOTALLOWED: &[u8] =
b">Request Not Allowed\n\nYou are not authorised to carry out the request.\n";
pub const IDENTITY_HASH_HEX_LEN: usize = 64;
#[cfg(unix)]
fn is_executable(metadata: &std::fs::Metadata) -> bool {
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
fn is_executable(_metadata: &std::fs::Metadata) -> bool {
false
}
#[derive(Clone, Debug, Default)]
pub struct AllowedList {
pub identities: HashSet<[u8; 32]>,
}
impl AllowedList {
pub fn from_bytes(data: &[u8]) -> Self {
let mut identities = HashSet::new();
for line in data.split(|&b| b == b'\n') {
let start = line
.iter()
.position(|&b| b != b' ' && b != b'\t' && b != b'\r')
.unwrap_or(line.len());
let line = &line[start..];
let end = line
.iter()
.rposition(|&b| b != b' ' && b != b'\t' && b != b'\r')
.map(|i| i + 1)
.unwrap_or(0);
let line = &line[..end];
if line.len() != IDENTITY_HASH_HEX_LEN {
continue;
}
if let Ok(bytes) = hex::decode(line) {
if bytes.len() == 32 {
let mut arr = [0u8; 32];
arr.copy_from_slice(&bytes);
identities.insert(arr);
}
}
}
Self { identities }
}
pub fn from_file(path: &Path) -> std::io::Result<Self> {
let data = std::fs::read(path)?;
Ok(Self::from_bytes(&data))
}
pub fn is_allowed(&self, identity_hash: Option<&[u8; 32]>) -> bool {
match identity_hash {
Some(hash) => self.identities.contains(hash),
None => false,
}
}
}
#[derive(Clone, Debug)]
pub struct PageAccessControl {
allowed: Option<AllowedList>,
executable_allowed: Option<PathBuf>,
}
impl PageAccessControl {
pub fn open() -> Self {
Self {
allowed: None,
executable_allowed: None,
}
}
pub fn restricted(allowed: AllowedList) -> Self {
Self {
allowed: Some(allowed),
executable_allowed: None,
}
}
pub fn restricted_executable(script_path: PathBuf) -> Self {
Self {
allowed: None,
executable_allowed: Some(script_path),
}
}
pub fn is_allowed(&self, identity_hash: Option<&[u8; 32]>) -> bool {
if let Some(ref list) = self.allowed {
return list.is_allowed(identity_hash);
}
if let Some(ref script) = self.executable_allowed {
match std::process::Command::new(script).output() {
Ok(output) if output.status.success() => {
let list = AllowedList::from_bytes(&output.stdout);
return list.is_allowed(identity_hash);
}
Ok(output) => {
warn!(
"PageAccessControl: script {} exited with {}",
script.display(),
output.status
);
return false;
}
Err(e) => {
warn!(
"PageAccessControl: failed to execute {}: {e}",
script.display()
);
return false;
}
}
}
true
}
}
pub fn check_page_access(page_path: &Path, identity_hash: Option<&[u8; 32]>) -> bool {
let page_str = page_path.to_string_lossy();
let allowed_path = PathBuf::from(format!("{}.allowed", page_str));
match allowed_path.metadata() {
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return true,
Err(e) => {
warn!(
"check_page_access: cannot stat {}: {e}",
allowed_path.display()
);
return false;
}
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = allowed_path.metadata() {
let mode = metadata.permissions().mode();
if mode & 0o111 != 0 {
let acl = PageAccessControl::restricted_executable(allowed_path);
return acl.is_allowed(identity_hash);
}
}
}
match AllowedList::from_file(&allowed_path) {
Ok(list) => list.is_allowed(identity_hash),
Err(e) => {
warn!(
"check_page_access: failed to read {}: {e}",
allowed_path.display()
);
false
}
}
}
#[derive(Debug, Default)]
pub struct NodeStats {
page_requests: AtomicU64,
file_requests: AtomicU64,
peer_connections: AtomicU64,
}
impl NodeStats {
pub fn new() -> Self {
Self::default()
}
pub fn increment_page_requests(&self) -> u64 {
self.page_requests.fetch_add(1, Ordering::Relaxed)
}
pub fn increment_file_requests(&self) -> u64 {
self.file_requests.fetch_add(1, Ordering::Relaxed)
}
pub fn increment_peer_connections(&self) -> u64 {
self.peer_connections.fetch_add(1, Ordering::Relaxed)
}
pub fn page_requests(&self) -> u64 {
self.page_requests.load(Ordering::Relaxed)
}
pub fn file_requests(&self) -> u64 {
self.file_requests.load(Ordering::Relaxed)
}
pub fn peer_connections(&self) -> u64 {
self.peer_connections.load(Ordering::Relaxed)
}
}
#[derive(Clone, Debug)]
pub struct PeerSettings {
pub display_name: String,
pub announce_interval_secs: u64,
pub last_announce: Option<f64>,
pub node_last_announce: Option<f64>,
pub node_connects: u64,
pub served_page_requests: u64,
pub served_file_requests: u64,
}
impl Default for PeerSettings {
fn default() -> Self {
Self {
display_name: "Anonymous Peer".to_string(),
announce_interval_secs: 43200,
last_announce: None,
node_last_announce: None,
node_connects: 0,
served_page_requests: 0,
served_file_requests: 0,
}
}
}
impl PeerSettings {
pub fn save_to_bytes(&self) -> Result<Vec<u8>, ciborium::ser::Error<std::io::Error>> {
let mut pairs = vec![
(
ciborium::value::Value::Text("display_name".to_string()),
ciborium::value::Value::Text(self.display_name.clone()),
),
(
ciborium::value::Value::Text("announce_interval_secs".to_string()),
ciborium::value::Value::Integer(self.announce_interval_secs.into()),
),
(
ciborium::value::Value::Text("node_connects".to_string()),
ciborium::value::Value::Integer(self.node_connects.into()),
),
(
ciborium::value::Value::Text("served_page_requests".to_string()),
ciborium::value::Value::Integer(self.served_page_requests.into()),
),
(
ciborium::value::Value::Text("served_file_requests".to_string()),
ciborium::value::Value::Integer(self.served_file_requests.into()),
),
];
if let Some(t) = self.last_announce {
pairs.push((
ciborium::value::Value::Text("last_announce".to_string()),
ciborium::value::Value::Float(t),
));
}
if let Some(t) = self.node_last_announce {
pairs.push((
ciborium::value::Value::Text("node_last_announce".to_string()),
ciborium::value::Value::Float(t),
));
}
let root = ciborium::value::Value::Map(pairs);
let mut buf = Vec::new();
ciborium::ser::into_writer(&root, &mut buf)?;
Ok(buf)
}
pub fn load_from_bytes(data: &[u8]) -> Option<Self> {
let root: ciborium::value::Value = ciborium::de::from_reader(data).ok()?;
let pairs = match &root {
ciborium::value::Value::Map(p) => p,
_ => return None,
};
let get_str = |key: &str| -> Option<String> {
pairs
.iter()
.find(|(k, _)| matches!(k, ciborium::value::Value::Text(t) if t == key))
.and_then(|(_, v)| match v {
ciborium::value::Value::Text(t) => Some(t.clone()),
_ => None,
})
};
let get_u64 = |key: &str| -> Option<u64> {
pairs
.iter()
.find(|(k, _)| matches!(k, ciborium::value::Value::Text(t) if t == key))
.and_then(|(_, v)| match v {
ciborium::value::Value::Integer(i) => Some((*i).try_into().ok()?),
_ => None,
})
};
let get_f64 = |key: &str| -> Option<f64> {
pairs
.iter()
.find(|(k, _)| matches!(k, ciborium::value::Value::Text(t) if t == key))
.and_then(|(_, v)| match v {
ciborium::value::Value::Float(f) => Some(*f),
ciborium::value::Value::Integer(i) => {
let v: i128 = (*i).into();
Some(v as f64)
}
_ => None,
})
};
Some(Self {
display_name: get_str("display_name").unwrap_or_else(|| "Anonymous Peer".to_string()),
announce_interval_secs: get_u64("announce_interval_secs").unwrap_or(43200),
last_announce: get_f64("last_announce"),
node_last_announce: get_f64("node_last_announce"),
node_connects: get_u64("node_connects").unwrap_or(0),
served_page_requests: get_u64("served_page_requests").unwrap_or(0),
served_file_requests: get_u64("served_file_requests").unwrap_or(0),
})
}
pub fn save(&self, path: &Path) -> std::io::Result<()> {
let data = self.save_to_bytes().map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("CBOR serialization: {e}"),
)
})?;
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let tmp_name = format!(
".peer_settings_{}_{}",
path.file_name().unwrap_or_default().to_string_lossy(),
std::process::id()
);
let tmp_path = parent.join(&tmp_name);
{
use std::io::Write;
let mut f = std::fs::File::create(&tmp_path)?;
f.write_all(&data)?;
f.flush()?;
let _ = f.sync_data();
}
#[cfg(windows)]
{
let _ = std::fs::remove_file(path);
}
let result = std::fs::rename(&tmp_path, path);
if result.is_err() {
let _ = std::fs::remove_file(&tmp_path);
}
result
}
pub fn load(path: &Path) -> Option<Self> {
let data = std::fs::read(path).ok()?;
Self::load_from_bytes(&data)
}
}
pub fn scan_pages(pages_path: &Path) -> Vec<PathBuf> {
let mut pages = Vec::new();
if !pages_path.is_dir() {
return pages;
}
if let Ok(entries) = std::fs::read_dir(pages_path) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
let name = path.file_name().unwrap_or_default().to_string_lossy();
if name.starts_with('.') {
continue;
}
if name.ends_with(".allowed") {
continue;
}
pages.push(path);
}
}
}
pages.sort();
pages
}
pub fn scan_files(files_path: &Path) -> Vec<PathBuf> {
scan_pages(files_path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_paginate_path_base() {
assert_eq!(paginate_path("/page/foo.mu", 1), "/page/foo.mu");
}
#[test]
fn test_paginate_path_subpages() {
assert_eq!(paginate_path("/page/foo.mu", 2), "/page/foo/2.mu");
assert_eq!(paginate_path("/page/foo.mu", 5), "/page/foo/5.mu");
assert_eq!(paginate_path("/page/bar baz.mu", 3), "/page/bar baz/3.mu");
}
#[test]
fn test_base_path_from_paginated() {
assert_eq!(
base_path_from_paginated("/page/foo/2.mu"),
("/page/foo.mu".to_string(), Some(2))
);
assert_eq!(
base_path_from_paginated("/page/foo/10.mu"),
("/page/foo.mu".to_string(), Some(10))
);
assert_eq!(
base_path_from_paginated("/page/foo.mu"),
("/page/foo.mu".to_string(), None)
);
assert_eq!(
base_path_from_paginated("/page/foo/1.mu"),
("/page/foo/1.mu".to_string(), None)
);
}
#[test]
fn test_split_into_chunks_small() {
let content = "line1\nline2";
let chunks = split_into_chunks(content, 280);
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0], "line1\nline2");
}
#[test]
fn test_split_into_chunks_splits_at_line_boundary() {
let mut content = String::new();
for i in 0..20 {
content.push_str(&format!("line {i:04} — some content here\n"));
}
let chunks = split_into_chunks(&content, 100);
assert!(chunks.len() > 1, "expected multiple chunks");
for chunk in &chunks {
assert!(chunk.len() <= 150, "chunk too large: {} bytes", chunk.len());
}
let reassembled: String = chunks.join("\n");
for i in 0..20 {
assert!(
reassembled.contains(&format!("line {i:04}")),
"missing line {i}"
);
}
}
#[test]
fn test_build_paginated_page_single() {
let page = build_paginated_page("Hello", 1, 1, "/page/test.mu", "abcd1234");
let text = String::from_utf8_lossy(&page);
assert!(text.contains("Hello"));
assert!(!text.contains("Previous"), "no prev link on single page");
assert!(!text.contains("Next"), "no next link on single page");
}
#[test]
fn test_build_paginated_page_first_of_many() {
let page = build_paginated_page("content", 1, 3, "/page/big.mu", "abcd1234");
let text = String::from_utf8_lossy(&page);
assert!(text.contains("content"));
assert!(!text.contains("Previous"), "no prev on first page");
assert!(text.contains("Next page >>"));
assert!(text.contains("Page 1 of 3"));
}
#[test]
fn test_build_paginated_page_middle() {
let page = build_paginated_page("content", 2, 3, "/page/big.mu", "abcd1234");
let text = String::from_utf8_lossy(&page);
assert!(text.contains("<< Previous page"));
assert!(text.contains("Next page >>"));
assert!(text.contains("Page 2 of 3"));
}
#[test]
fn test_build_paginated_page_last() {
let page = build_paginated_page("content", 3, 3, "/page/big.mu", "abcd1234");
let text = String::from_utf8_lossy(&page);
assert!(text.contains("<< Previous page"));
assert!(!text.contains("Next"), "no next on last page");
assert!(text.contains("Page 3 of 3"));
}
#[test]
fn test_paginated_pages_within_max_response() {
let mut big_content = String::new();
for i in 0..50 {
big_content.push_str(&format!(
"This is line number {i} with some padding text.\n"
));
}
let chunks = split_into_chunks(&big_content, CHUNK_TARGET_BYTES);
for (idx, chunk) in chunks.iter().enumerate() {
let page =
build_paginated_page(chunk, idx + 1, chunks.len(), "/page/big.mu", "abcd1234");
assert!(
page.len() <= MAX_RESPONSE_BYTES,
"page {} is {} bytes, exceeds max {}",
idx + 1,
page.len(),
MAX_RESPONSE_BYTES
);
}
}
#[test]
fn test_allowed_list_from_bytes() {
let hash = [0xAA; 32];
let hex_hash = hex::encode(hash);
let data = format!("{}\n", hex_hash);
let list = AllowedList::from_bytes(data.as_bytes());
assert!(list.is_allowed(Some(&hash)));
assert!(!list.is_allowed(Some(&[0xBB; 32])));
assert!(!list.is_allowed(None));
}
#[test]
fn test_allowed_list_skips_invalid() {
let data = "invalid\n001122\n\naa\n";
let list = AllowedList::from_bytes(data.as_bytes());
assert!(list.identities.is_empty());
}
#[test]
fn test_allowed_list_multiple_entries() {
let h1 = [0xAA; 32];
let h2 = [0xBB; 32];
let data = format!("{}\n{}\n", hex::encode(h1), hex::encode(h2));
let list = AllowedList::from_bytes(data.as_bytes());
assert!(list.is_allowed(Some(&h1)));
assert!(list.is_allowed(Some(&h2)));
assert_eq!(list.identities.len(), 2);
}
#[test]
fn test_access_control_open() {
let acl = PageAccessControl::open();
assert!(acl.is_allowed(None));
assert!(acl.is_allowed(Some(&[0xAA; 32])));
}
#[test]
fn test_node_stats_atomic() {
let stats = NodeStats::new();
assert_eq!(stats.page_requests(), 0);
stats.increment_page_requests();
stats.increment_page_requests();
assert_eq!(stats.page_requests(), 2);
assert_eq!(stats.file_requests(), 0);
stats.increment_file_requests();
assert_eq!(stats.file_requests(), 1);
}
#[test]
fn test_peer_settings_roundtrip() {
let settings = PeerSettings {
display_name: "TestPeer".to_string(),
announce_interval_secs: 600,
last_announce: Some(12345.0),
node_last_announce: None,
node_connects: 42,
served_page_requests: 100,
served_file_requests: 50,
};
let bytes = settings.save_to_bytes().unwrap();
let loaded = PeerSettings::load_from_bytes(&bytes).unwrap();
assert_eq!(loaded.display_name, "TestPeer");
assert_eq!(loaded.announce_interval_secs, 600);
assert_eq!(loaded.last_announce, Some(12345.0));
assert_eq!(loaded.node_last_announce, None);
assert_eq!(loaded.node_connects, 42);
assert_eq!(loaded.served_page_requests, 100);
assert_eq!(loaded.served_file_requests, 50);
}
#[test]
fn test_peer_settings_save_load_file() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("peer_settings");
let settings = PeerSettings {
display_name: "FilePeer".to_string(),
served_page_requests: 999,
..Default::default()
};
settings.save(&path).unwrap();
let loaded = PeerSettings::load(&path).unwrap();
assert_eq!(loaded.display_name, "FilePeer");
assert_eq!(loaded.served_page_requests, 999);
}
#[test]
fn test_scan_pages_skips_hidden_and_allowed() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("index.mu"), "page").unwrap();
std::fs::write(tmp.path().join("index.mu.allowed"), "abc").unwrap();
std::fs::write(tmp.path().join(".hidden"), "hidden").unwrap();
std::fs::write(tmp.path().join("other.mu"), "page2").unwrap();
let pages = scan_pages(tmp.path());
assert_eq!(pages.len(), 2);
let names: Vec<String> = pages
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().to_string())
.collect();
assert!(names.contains(&"index.mu".to_string()));
assert!(names.contains(&"other.mu".to_string()));
assert!(!names.contains(&"index.mu.allowed".to_string()));
assert!(!names.contains(&".hidden".to_string()));
}
#[test]
fn test_default_not_allowed_is_micron() {
let text = String::from_utf8_lossy(DEFAULT_NOTALLOWED);
assert!(text.starts_with(">Request Not Allowed"));
}
#[test]
fn test_request_data_empty_bytes() {
let rd = RequestData::from_bytes(&[]);
assert!(rd.is_empty());
assert!(rd.fields().is_empty());
assert!(rd.vars().is_empty());
}
#[test]
fn test_request_data_invalid_bytes() {
let rd = RequestData::from_bytes(&[0xFF, 0xFE]);
assert!(rd.is_empty());
}
#[test]
fn test_request_data_parses_fields_and_vars() {
let mut map = HashMap::new();
map.insert("field_username".to_string(), "alice".to_string());
map.insert("field_password".to_string(), "secret".to_string());
map.insert("var_source".to_string(), "homepage".to_string());
map.insert("var_version".to_string(), "2".to_string());
map.insert("other_key".to_string(), "ignored".to_string());
let bytes = rmp_serde::to_vec(&map).unwrap();
let rd = RequestData::from_bytes(&bytes);
assert!(!rd.is_empty());
assert_eq!(rd.field("username"), Some("alice"));
assert_eq!(rd.field("password"), Some("secret"));
assert_eq!(rd.var("source"), Some("homepage"));
assert_eq!(rd.var("version"), Some("2"));
assert_eq!(rd.field("other_key"), None);
assert_eq!(rd.var("other_key"), None);
}
#[test]
fn test_request_data_to_env_map() {
let mut map = HashMap::new();
map.insert("field_name".to_string(), "bob".to_string());
map.insert("var_page".to_string(), "1".to_string());
let bytes = rmp_serde::to_vec(&map).unwrap();
let rd = RequestData::from_bytes(&bytes);
let env = rd.to_env_map();
assert_eq!(env.get("field_name"), Some(&"bob".to_string()));
assert_eq!(env.get("var_page"), Some(&"1".to_string()));
assert_eq!(env.len(), 2);
}
#[test]
fn test_serve_page_returns_cached_content() {
let cache = PageCache::new();
cache.set("/page/hello.mu", b"> Hello".to_vec());
let result = serve_page_inner(
"/page/hello.mu",
&[],
[0u8; 16],
None,
&cache,
"deadbeef",
None,
);
assert!(result.starts_with(b"> Hello"));
}
#[test]
fn test_serve_page_returns_default_index_for_missing_index() {
let cache = PageCache::new();
let result = serve_page_inner(
"/page/index.mu",
&[],
[0u8; 16],
None,
&cache,
"deadbeef",
None,
);
assert!(
String::from_utf8_lossy(&result).contains("Default Home Page"),
"expected default index content, got: {:?}",
String::from_utf8_lossy(&result)
);
}
#[test]
fn test_serve_page_returns_404_for_missing_non_index() {
let cache = PageCache::new();
let result = serve_page_inner(
"/page/missing.mu",
&[],
[0u8; 16],
None,
&cache,
"deadbeef",
None,
);
assert!(
String::from_utf8_lossy(&result).contains("404"),
"expected 404 content, got: {:?}",
String::from_utf8_lossy(&result)
);
}
#[test]
fn test_path_traversal_blocked() {
let tmp = tempfile::tempdir().unwrap();
let pages_dir = tmp.path().join("pages");
std::fs::create_dir_all(&pages_dir).unwrap();
let cache = PageCache::new();
let result = serve_page_inner(
"/page/../../../etc/passwd",
&[],
[0u8; 16],
None,
&cache,
"deadbeef",
Some(&pages_dir),
);
assert!(
!String::from_utf8_lossy(&result).contains("root:"),
"path traversal should be blocked"
);
}
#[test]
fn test_is_executable_detects_exec_bit() {
let tmp = tempfile::tempdir().unwrap();
let script = tmp.path().join("script.sh");
std::fs::write(&script, "#!/bin/sh\ntrue").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o644)).unwrap();
let meta = script.metadata().unwrap();
assert!(!is_executable(&meta));
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
let meta = script.metadata().unwrap();
assert!(is_executable(&meta));
}
}
#[test]
fn test_run_cgi_produces_output() {
let tmp = tempfile::tempdir().unwrap();
let script = tmp.path().join("echo_name.sh");
std::fs::write(&script, "#!/bin/sh\necho \"Hello $field_name\"").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let _page_cache = PageCache::new();
let _dest_hash = [0xAA; 16];
#[cfg(unix)]
{
let mut map = HashMap::new();
map.insert("field_name".to_string(), "world".to_string());
let data = rmp_serde::to_vec(&map).unwrap();
let mut env = RequestData::from_bytes(&data).to_env_map();
if let Ok(path_var) = std::env::var("PATH") {
env.insert("PATH".to_string(), path_var);
}
env.insert("link_id".to_string(), hex::encode([0x22u8; 16]));
let output = Command::new(&script)
.env_clear()
.envs(&env)
.output()
.unwrap();
let text = String::from_utf8_lossy(&output.stdout);
assert!(text.contains("Hello world"), "got: {text}");
}
}
#[test]
fn test_set_on_announce_callback() {
use std::sync::atomic::{AtomicUsize, Ordering};
let counter = Arc::new(AtomicUsize::new(0));
let counter_clone = counter.clone();
let cb: Option<Arc<dyn Fn() + Send + Sync>> = Some(Arc::new(move || {
counter_clone.fetch_add(1, Ordering::SeqCst);
}));
let mut node = NomadNode {
dest_hash: [0u8; 16],
identity_hash: [0u8; 16],
identity_prv: [0u8; 64],
node_name: "test".to_string(),
announce_interval_secs: 600,
page_cache: PageCache::new(),
file_cache: FileCache::new(),
pages_dir: None,
on_announce: None,
};
assert!(node.on_announce.is_none());
node.set_on_announce(cb);
assert!(node.on_announce.is_some());
(node.on_announce.as_ref().unwrap())();
assert_eq!(counter.load(Ordering::SeqCst), 1);
}
#[test]
fn test_on_announce_catches_panic() {
use std::sync::atomic::{AtomicBool, Ordering};
let panicked = Arc::new(AtomicBool::new(false));
let panicked_clone = panicked.clone();
let cb: Arc<dyn Fn() + Send + Sync> = Arc::new(move || {
panicked_clone.store(true, Ordering::SeqCst);
panic!("intentional test panic");
});
let rt = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap();
rt.block_on(async {
let handle = tokio::spawn(async move {
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
cb();
}));
});
let _ = handle.await;
});
assert!(panicked.load(Ordering::SeqCst));
}
#[test]
fn test_encode_decode_file_response_roundtrip() {
let encoded = encode_file_response("document.pdf", b"file content here").unwrap();
let (name, content) = decode_file_response(&encoded).unwrap();
assert_eq!(name, "document.pdf");
assert_eq!(content, b"file content here");
}
#[test]
fn test_decode_file_response_invalid_data_returns_none() {
assert!(decode_file_response(b"not msgpack at all").is_none());
assert!(decode_file_response(&[]).is_none());
}
#[test]
fn test_encode_file_response_with_empty_name() {
let encoded = encode_file_response("", b"data").unwrap();
let (name, content) = decode_file_response(&encoded).unwrap();
assert_eq!(name, "");
assert_eq!(content, b"data");
}
#[test]
fn test_encode_file_response_strips_path_to_basename() {
let encoded = encode_file_response("docs/reports/document.pdf", b"pdf").unwrap();
let (name, content) = decode_file_response(&encoded).unwrap();
assert_eq!(name, "document.pdf");
assert_eq!(content, b"pdf");
}
#[test]
fn test_encode_file_response_unicode_name() {
let encoded = encode_file_response("ドキュメント.pdf", b"data").unwrap();
let (name, content) = decode_file_response(&encoded).unwrap();
assert_eq!(name, "ドキュメント.pdf");
assert_eq!(content, b"data");
}
}