use std::path::{Component, Path, PathBuf};
include!(concat!(env!("OUT_DIR"), "/embedded_dashboard.rs"));
fn embedded_asset(relative: &str) -> Option<&'static [u8]> {
EMBEDDED_DASHBOARD
.iter()
.find(|(name, _)| *name == relative)
.map(|(_, bytes)| *bytes)
}
pub(crate) const WEB_ROOT_ENV: &str = "NOMOREIDE_WEB_ROOT";
const SHELL_PATHS: &[&str] = &[
"/",
"/services",
"/activity",
"/remote",
"/servers",
"/docker",
"/git",
"/github",
"/linear",
"/agent",
"/agent-env",
"/context",
"/extensions",
"/errors",
"/database",
"/settings",
];
const SHELL_PREFIXES: &[&str] = &["/extensions/"];
pub(crate) fn normalize_request_path(path: &str) -> String {
let mut segments: Vec<&str> = Vec::new();
let mut trailing_slash = false;
for segment in path.split('/') {
match dot_segment(segment) {
Some(Dots::One) => trailing_slash = true,
Some(Dots::Two) => {
segments.pop();
trailing_slash = true;
}
None => {
if segment.is_empty() {
trailing_slash = true;
continue;
}
segments.push(segment);
trailing_slash = false;
}
}
}
let mut normalized = String::from("/");
normalized.push_str(&segments.join("/"));
if trailing_slash && !normalized.ends_with('/') {
normalized.push('/');
}
normalized
}
enum Dots {
One,
Two,
}
fn dot_segment(segment: &str) -> Option<Dots> {
match segment.to_ascii_lowercase().as_str() {
"." | "%2e" => Some(Dots::One),
".." | "%2e." | ".%2e" | "%2e%2e" => Some(Dots::Two),
_ => None,
}
}
pub(crate) fn serves_shell(pathname: &str) -> bool {
if SHELL_PATHS.contains(&pathname) {
return true;
}
SHELL_PREFIXES.iter().any(|prefix| {
pathname.starts_with(prefix) && pathname.len() > prefix.len()
})
}
pub(crate) fn read_shell() -> Result<String, String> {
for root in asset_roots() {
if let Ok(html) = std::fs::read_to_string(root.join("index.html")) {
return Ok(html);
}
}
if let Some(bytes) = embedded_asset("index.html") {
if let Ok(html) = std::str::from_utf8(bytes) {
return Ok(html.to_string());
}
}
for root in repo_candidates() {
if let Ok(html) = std::fs::read_to_string(root.join("apps/dashboard/index.html")) {
return Ok(html);
}
}
Err("React web app shell was not found. Run npm run build.".to_string())
}
pub(crate) fn read_asset(request_path: &str) -> Option<(Vec<u8>, &'static str)> {
let relative = request_path.trim_start_matches('/');
for root in asset_roots() {
let Some(path) = resolve_inside(&root, relative) else {
continue;
};
if let Ok(bytes) = std::fs::read(&path) {
return Some((bytes, content_type_for(&path)));
}
}
embedded_asset(relative).map(|bytes| (bytes.to_vec(), content_type_for(Path::new(relative))))
}
fn resolve_inside(root: &Path, relative: &str) -> Option<PathBuf> {
let mut resolved = root.to_path_buf();
for component in Path::new(relative).components() {
match component {
Component::Normal(part) => resolved.push(part),
Component::CurDir => {}
Component::ParentDir => {
if !resolved.pop() || !resolved.starts_with(root) {
return None;
}
}
Component::RootDir | Component::Prefix(_) => return None,
}
}
resolved.starts_with(root).then_some(resolved)
}
fn asset_roots() -> Vec<PathBuf> {
let mut roots = Vec::new();
if let Some(configured) = std::env::var_os(WEB_ROOT_ENV) {
roots.push(PathBuf::from(configured));
}
for candidate in repo_candidates() {
roots.push(candidate.join("dist/web/client"));
}
if let Some(directory) = executable_directory() {
roots.push(directory.join("web/client"));
if let Some(prefix) = directory.parent() {
roots.push(prefix.join("share/nomoreide/web/client"));
}
}
roots
}
fn repo_candidates() -> Vec<PathBuf> {
let Some(directory) = executable_directory() else {
return Vec::new();
};
let mut candidates = Vec::new();
let mut current = Some(directory.as_path());
for _ in 0..4 {
let Some(path) = current else { break };
candidates.push(path.to_path_buf());
current = path.parent();
}
candidates
}
fn executable_directory() -> Option<PathBuf> {
std::env::current_exe()
.ok()?
.parent()
.map(Path::to_path_buf)
}
fn content_type_for(path: &Path) -> &'static str {
match path.extension().and_then(|value| value.to_str()) {
Some("css") => "text/css; charset=utf-8",
Some("js") => "text/javascript; charset=utf-8",
Some("svg") => "image/svg+xml",
Some("png") => "image/png",
Some("woff2") => "font/woff2",
_ => "application/octet-stream",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_plain_path_normalizes_to_itself() {
assert_eq!(normalize_request_path("/assets/app.js"), "/assets/app.js");
assert_eq!(normalize_request_path("/"), "/");
}
#[test]
fn dot_segments_collapse_the_way_a_url_parser_collapses_them() {
assert_eq!(
normalize_request_path("/assets/../index.html"),
"/index.html"
);
assert_eq!(normalize_request_path("/assets/./app.js"), "/assets/app.js");
assert_eq!(
normalize_request_path("/assets/../../../package.json"),
"/package.json"
);
assert_eq!(
normalize_request_path("/assets/../../client-evil/secret.js"),
"/client-evil/secret.js"
);
}
#[test]
fn climbing_past_the_root_stops_at_it() {
assert_eq!(normalize_request_path("/../../etc/passwd"), "/etc/passwd");
assert_eq!(normalize_request_path("/.."), "/");
}
#[test]
fn percent_encoded_dots_are_dot_segments() {
assert_eq!(
normalize_request_path("/assets/%2e%2e/package.json"),
"/package.json"
);
assert_eq!(
normalize_request_path("/assets/%2E./package.json"),
"/package.json"
);
assert_eq!(
normalize_request_path("/assets/%2e/app.js"),
"/assets/app.js"
);
}
#[test]
fn a_trailing_slash_survives() {
assert_eq!(normalize_request_path("/assets/"), "/assets/");
assert_eq!(normalize_request_path("/extensions/"), "/extensions/");
}
#[test]
fn known_pages_serve_the_shell() {
assert!(serves_shell("/"));
assert!(serves_shell("/services"));
assert!(serves_shell("/agent-env"));
assert!(serves_shell("/remote"));
}
#[test]
fn every_client_page_serves_the_shell() {
for path in [
"/",
"/services",
"/activity",
"/remote",
"/servers",
"/docker",
"/git",
"/github",
"/agent",
"/agent-env",
"/context",
"/extensions",
"/errors",
"/database",
"/settings",
] {
assert!(serves_shell(path), "{path} would 404 on refresh");
}
}
#[test]
fn unknown_paths_do_not_serve_the_shell() {
assert!(!serves_shell("/nope"));
assert!(!serves_shell("/api/status"));
assert!(!serves_shell("/services/extra"));
}
#[test]
fn an_extension_id_serves_the_shell_but_the_bare_prefix_does_not() {
assert!(serves_shell("/extensions/some-plugin"));
assert!(
!serves_shell("/extensions/"),
"a trailing slash names no plugin"
);
assert!(serves_shell("/extensions"));
}
#[test]
fn a_path_inside_the_root_resolves() {
let root = Path::new("/srv/client");
assert_eq!(
resolve_inside(root, "assets/app.js"),
Some(PathBuf::from("/srv/client/assets/app.js"))
);
}
#[test]
fn dot_segments_that_stay_inside_are_allowed() {
let root = Path::new("/srv/client");
assert_eq!(
resolve_inside(root, "assets/../assets/./app.js"),
Some(PathBuf::from("/srv/client/assets/app.js"))
);
}
#[test]
fn climbing_out_of_the_root_is_refused() {
let root = Path::new("/srv/client");
assert_eq!(resolve_inside(root, "../secret"), None);
assert_eq!(resolve_inside(root, "assets/../../secret"), None);
}
#[test]
fn a_sibling_whose_name_extends_the_root_is_refused() {
let root = Path::new("/srv/client");
assert_eq!(resolve_inside(root, "../client-evil/secret"), None);
assert_eq!(
resolve_inside(root, "assets/../../client-evil/secret"),
None
);
}
#[test]
fn an_absolute_request_cannot_replace_the_root() {
let root = Path::new("/srv/client");
assert_eq!(resolve_inside(root, "/etc/passwd"), None);
}
#[test]
fn a_built_tree_embeds_its_dashboard() {
if EMBEDDED_DASHBOARD.is_empty() {
return;
}
assert!(
embedded_asset("index.html").is_some(),
"the shell is the one file an embedded dashboard cannot be missing"
);
assert!(
EMBEDDED_DASHBOARD
.iter()
.any(|(name, _)| name.starts_with("assets/")),
"index.html alone is a shell with nothing to load"
);
}
#[test]
fn embedded_keys_are_request_shaped() {
for (name, _) in EMBEDDED_DASHBOARD {
assert!(!name.starts_with('/'), "{name} carries a leading slash");
assert!(!name.contains('\\'), "{name} is spelled with backslashes");
}
}
#[test]
fn the_embedded_table_cannot_be_escaped() {
assert_eq!(embedded_asset("../Cargo.toml"), None);
assert_eq!(embedded_asset("/etc/passwd"), None);
}
#[test]
fn content_types_match_the_reference_switch() {
assert_eq!(
content_type_for(Path::new("a.css")),
"text/css; charset=utf-8"
);
assert_eq!(
content_type_for(Path::new("a.js")),
"text/javascript; charset=utf-8"
);
assert_eq!(content_type_for(Path::new("a.svg")), "image/svg+xml");
assert_eq!(content_type_for(Path::new("a.png")), "image/png");
assert_eq!(content_type_for(Path::new("a.woff2")), "font/woff2");
assert_eq!(
content_type_for(Path::new("a.ttf")),
"application/octet-stream"
);
assert_eq!(content_type_for(Path::new("a")), "application/octet-stream");
}
}