use std::{
path::{Path, PathBuf},
sync::{Arc, LazyLock},
time::Duration,
};
use axum::response::{Html, IntoResponse};
use dashmap::DashMap;
use freenet_stdlib::{
client_api::{ClientRequest, ContractRequest, ContractResponse, HostResponse},
prelude::*,
};
use tokio::time::Instant;
use tokio::{fs::File, io::AsyncReadExt, sync::mpsc};
use crate::client_events::AuthToken;
use super::{
ApiVersion, ClientConnection, HostCallbackResult,
app_packaging::{WebApp, WebContractError},
client_api::HttpClientApiRequest,
errors::WebSocketApiError,
};
use tracing::{debug, instrument};
static CONTRACT_CACHE_LOCKS: LazyLock<DashMap<ContractInstanceId, Arc<tokio::sync::Mutex<()>>>> =
LazyLock::new(DashMap::new);
async fn acquire_cache_lock(instance_id: &ContractInstanceId) -> tokio::sync::OwnedMutexGuard<()> {
let mutex = CONTRACT_CACHE_LOCKS
.entry(*instance_id)
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
.clone();
mutex.lock_owned().await
}
const CONTRACT_CACHE_REFRESH_TTL: Duration = Duration::from_secs(30);
const PRESENCE_QUERY_TIMEOUT: Duration = Duration::from_secs(5);
static CONTRACT_CACHE_REFRESH: LazyLock<DashMap<ContractInstanceId, Instant>> =
LazyLock::new(DashMap::new);
static CONTRACT_REFRESH_LOCKS: LazyLock<DashMap<ContractInstanceId, Arc<tokio::sync::Mutex<()>>>> =
LazyLock::new(DashMap::new);
async fn acquire_refresh_lock(
instance_id: &ContractInstanceId,
) -> tokio::sync::OwnedMutexGuard<()> {
let mutex = CONTRACT_REFRESH_LOCKS
.entry(*instance_id)
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
.clone();
mutex.lock_owned().await
}
fn cache_reconciled_recently(instance_id: &ContractInstanceId) -> bool {
CONTRACT_CACHE_REFRESH
.get(instance_id)
.map(|last| last.elapsed() < CONTRACT_CACHE_REFRESH_TTL)
.unwrap_or(false)
}
async fn is_locally_known(
instance_id: ContractInstanceId,
request_sender: &HttpClientApiRequest,
) -> bool {
use freenet_stdlib::client_api::{NodeDiagnosticsConfig, NodeQuery, QueryResponse};
let (response_sender, mut response_recv) = mpsc::unbounded_channel();
if request_sender
.send(ClientConnection::NewConnection {
callbacks: response_sender,
assigned_token: None,
})
.await
.is_err()
{
return false;
}
let client_id = match tokio::time::timeout(PRESENCE_QUERY_TIMEOUT, response_recv.recv()).await {
Ok(Some(HostCallbackResult::NewId { id })) => id,
_ => return false,
};
let key = freenet_stdlib::prelude::ContractKey::from_id_and_code(
instance_id,
freenet_stdlib::prelude::CodeHash::new([0u8; 32]),
);
let config = NodeDiagnosticsConfig {
include_node_info: false,
include_network_info: false,
include_subscriptions: true,
contract_keys: vec![key],
include_system_metrics: false,
include_detailed_peer_info: false,
include_subscriber_peer_ids: false,
};
let mut known = false;
if request_sender
.send(ClientConnection::Request {
client_id,
req: Box::new(ClientRequest::NodeQueries(NodeQuery::NodeDiagnostics {
config,
})),
auth_token: None,
origin_contract: None,
user_context: None,
api_version: Default::default(),
})
.await
.is_ok()
{
let recv_result = tokio::time::timeout(PRESENCE_QUERY_TIMEOUT, response_recv.recv()).await;
if let Ok(Some(HostCallbackResult::Result {
result: Ok(HostResponse::QueryResponse(QueryResponse::NodeDiagnostics(info))),
..
})) = recv_result
{
let in_store = info.contract_states.contains_key(&instance_id.to_string());
let subscribed = info
.subscriptions
.iter()
.any(|sub| sub.contract_key == instance_id);
known = in_store || subscribed;
}
}
if let Err(err) = request_sender
.send(ClientConnection::Request {
client_id,
req: Box::new(ClientRequest::Disconnect { cause: None }),
auth_token: None,
origin_contract: None,
user_context: None,
api_version: Default::default(),
})
.await
{
tracing::warn!("is_locally_known: disconnect send failed: {err}");
}
known
}
async fn refresh_cache_if_due(
instance_id: ContractInstanceId,
request_sender: &HttpClientApiRequest,
) -> Result<(), WebSocketApiError> {
let hash_path = state_hash_path(&instance_id);
let cache_warm = tokio::fs::try_exists(&hash_path).await.unwrap_or(false);
if cache_warm && cache_reconciled_recently(&instance_id) {
return Ok(());
}
let _guard = acquire_refresh_lock(&instance_id).await;
if cache_reconciled_recently(&instance_id) {
return Ok(());
}
if !cache_warm && !is_locally_known(instance_id, request_sender).await {
return Ok(());
}
ensure_contract_cached(instance_id, request_sender, None).await?;
CONTRACT_CACHE_REFRESH.insert(instance_id, Instant::now());
Ok(())
}
#[instrument(level = "debug", skip(request_sender))]
pub(super) async fn contract_home(
key: String,
request_sender: HttpClientApiRequest,
assigned_token: AuthToken,
api_version: ApiVersion,
query_string: Option<String>,
sub_path: Option<&str>,
hosted_mode: bool,
) -> Result<impl IntoResponse, WebSocketApiError> {
let instance_id = ContractInstanceId::from_bytes(&key).map_err(|err| {
debug!("contract_home: Failed to parse contract key: {}", err);
WebSocketApiError::InvalidParam {
error_cause: format!("{err}"),
}
})?;
ensure_contract_cached(
instance_id,
&request_sender,
Some((assigned_token.clone(), instance_id)),
)
.await?;
CONTRACT_CACHE_REFRESH.insert(instance_id, Instant::now());
match shell_page(
&assigned_token,
&key,
api_version,
query_string,
sub_path,
hosted_mode,
) {
Ok(b) => Ok(b.into_response()),
Err(err) => {
tracing::error!("Failed to generate shell page: {err}");
Err(WebSocketApiError::NodeError {
error_cause: format!("Failed to generate shell page: {err}"),
})
}
}
}
async fn ensure_contract_cached(
instance_id: ContractInstanceId,
request_sender: &HttpClientApiRequest,
assigned_token: Option<(AuthToken, ContractInstanceId)>,
) -> Result<(), WebSocketApiError> {
let (response_sender, mut response_recv) = mpsc::unbounded_channel();
request_sender
.send(ClientConnection::NewConnection {
callbacks: response_sender,
assigned_token,
})
.await
.map_err(|err| WebSocketApiError::NodeError {
error_cause: format!("{err}"),
})?;
let client_id = if let Some(HostCallbackResult::NewId { id }) = response_recv.recv().await {
id
} else {
return Err(WebSocketApiError::NodeError {
error_cause: "Couldn't register new client in the node".into(),
});
};
request_sender
.send(ClientConnection::Request {
client_id,
req: Box::new(
ContractRequest::Get {
key: instance_id,
return_contract_code: true,
subscribe: true,
blocking_subscribe: false,
}
.into(),
),
auth_token: None,
origin_contract: None,
user_context: None,
api_version: Default::default(),
})
.await
.map_err(|err| WebSocketApiError::NodeError {
error_cause: format!("{err}"),
})?;
let recv_result =
tokio::time::timeout(std::time::Duration::from_secs(30), response_recv.recv()).await;
let outcome = handle_get_response(instance_id, recv_result).await;
if let Err(err) = request_sender
.send(ClientConnection::Request {
client_id,
req: Box::new(ClientRequest::Disconnect { cause: None }),
auth_token: None,
origin_contract: None,
user_context: None,
api_version: Default::default(),
})
.await
{
tracing::warn!("ensure_contract_cached: disconnect send failed: {err}");
}
outcome
}
async fn handle_get_response(
instance_id: ContractInstanceId,
recv_result: Result<Option<HostCallbackResult>, tokio::time::error::Elapsed>,
) -> Result<(), WebSocketApiError> {
match recv_result {
Err(_) => Err(WebSocketApiError::NodeError {
error_cause: "GET request timed out after 30s".into(),
}),
Ok(None) => Err(WebSocketApiError::NodeError {
error_cause: "GET response channel closed (node may be shutting down)".into(),
}),
Ok(Some(HostCallbackResult::Result {
result:
Ok(HostResponse::ContractResponse(ContractResponse::GetResponse {
contract: Some(contract),
state,
..
})),
..
})) => unpack_if_stale(&contract, state.as_ref()).await,
Ok(Some(HostCallbackResult::Result {
result:
Ok(HostResponse::ContractResponse(ContractResponse::GetResponse {
contract: None, ..
})),
..
})) => Err(WebSocketApiError::MissingContract { instance_id }),
Ok(Some(HostCallbackResult::Result {
result: Err(err), ..
})) => {
tracing::error!("error getting contract `{}`: {err}", instance_id.encode());
Err(WebSocketApiError::AxumError {
error: err.kind().clone(),
})
}
Ok(other) => {
tracing::error!("Unexpected node response: {other:?}");
Err(WebSocketApiError::NodeError {
error_cause: format!("Unexpected response from node: {other:?}"),
})
}
}
}
async fn unpack_if_stale(
contract: &ContractContainer,
state_bytes: &[u8],
) -> Result<(), WebSocketApiError> {
let contract_key = contract.key();
let instance_id = *contract_key.id();
let path = contract_web_path(&instance_id);
let current_hash = hash_state(state_bytes);
let hash_path = state_hash_path(&instance_id);
let _guard = acquire_cache_lock(&instance_id).await;
let needs_update = match tokio::fs::read(&hash_path).await {
Ok(stored_hash_bytes) if stored_hash_bytes.len() == 8 => {
let stored_hash = u64::from_be_bytes(stored_hash_bytes.try_into().unwrap());
stored_hash != current_hash
}
_ => true,
};
if !needs_update {
return Ok(());
}
debug!("State changed or not cached, unpacking webapp");
let state = State::from(state_bytes);
fn err(err: WebContractError, contract: &ContractContainer) -> WebSocketApiError {
let key = contract.key();
tracing::error!("{err}");
WebSocketApiError::InvalidParam {
error_cause: format!("failed unpacking contract: {key}"),
}
}
let _cleanup = tokio::fs::remove_dir_all(&path).await;
tokio::fs::create_dir_all(&path)
.await
.map_err(|e| WebSocketApiError::NodeError {
error_cause: format!("Failed to create cache dir: {e}"),
})?;
let mut web = WebApp::try_from(state.as_ref()).map_err(|e| err(e, contract))?;
web.unpack(&path).map_err(|e| err(e, contract))?;
tokio::fs::write(&hash_path, current_hash.to_be_bytes())
.await
.map_err(|e| WebSocketApiError::NodeError {
error_cause: format!("Failed to write state hash: {e}"),
})?;
Ok(())
}
#[instrument(level = "debug", skip(request_sender))]
pub(super) async fn variable_content(
key: String,
req_path: String,
api_version: ApiVersion,
request_sender: HttpClientApiRequest,
) -> Result<impl IntoResponse, Box<WebSocketApiError>> {
debug!(
"variable_content: Processing request for key: {}, path: {}",
key, req_path
);
let instance_id =
ContractInstanceId::from_bytes(&key).map_err(|err| WebSocketApiError::InvalidParam {
error_cause: format!("{err}"),
})?;
let base_path = contract_web_path(&instance_id);
debug!("variable_content: Base path resolved to: {:?}", base_path);
refresh_cache_if_due(instance_id, &request_sender)
.await
.map_err(Box::new)?;
let req_uri =
req_path
.parse::<axum::http::Uri>()
.map_err(|err| WebSocketApiError::InvalidParam {
error_cause: format!("Failed to parse request path as URI: {err}"),
})?;
debug!("variable_content: Parsed request URI: {:?}", req_uri);
let relative_path = get_file_path(req_uri)?;
debug!(
"variable_content: Extracted relative path: {}",
relative_path
);
let file_path = base_path.join(relative_path);
debug!("variable_content: Full file path to serve: {:?}", file_path);
debug!(
"variable_content: Checking if file exists: {}",
file_path.exists()
);
if file_path.extension().is_some_and(|ext| ext == "js") {
let content = tokio::fs::read_to_string(&file_path).await.map_err(|err| {
WebSocketApiError::NodeError {
error_cause: format!("{err}"),
}
})?;
let prefix = format!("/{}/contract/web/{key}/", api_version.prefix());
let rewritten = content
.replace("\"/./", &format!("\"{prefix}"))
.replace("'/./", &format!("'{prefix}"));
return Ok((
[(axum::http::header::CONTENT_TYPE, "application/javascript")],
rewritten,
)
.into_response());
}
let mut serve_file = tower_http::services::fs::ServeFile::new(&file_path);
let fake_req = axum::http::Request::new(axum::body::Body::empty());
serve_file
.try_call(fake_req)
.await
.map_err(|err| {
WebSocketApiError::NodeError {
error_cause: format!("{err}"),
}
.into()
})
.map(|r| r.into_response())
}
fn html_escape_attr(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'&' => out.push_str("&"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
_ => out.push(ch),
}
}
out
}
fn sanitize_shell_sub_path(sub_path: &str) -> Result<String, WebSocketApiError> {
if sub_path.starts_with('/') {
return Err(WebSocketApiError::InvalidParam {
error_cause: "deep-link sub-path must be relative".to_string(),
});
}
if sub_path
.chars()
.any(|c| c.is_control() || c.is_whitespace() || matches!(c, '?' | '#' | '\\'))
{
return Err(WebSocketApiError::InvalidParam {
error_cause: "deep-link sub-path contains an illegal character".to_string(),
});
}
if sub_path.split('/').any(|seg| seg == "." || seg == "..") {
return Err(WebSocketApiError::InvalidParam {
error_cause: "deep-link sub-path must not contain '.' or '..' segments".to_string(),
});
}
Ok(sub_path.to_string())
}
fn shell_page(
auth_token: &AuthToken,
contract_key: &str,
api_version: ApiVersion,
query_string: Option<String>,
sub_path: Option<&str>,
hosted_mode: bool,
) -> Result<impl IntoResponse, WebSocketApiError> {
let version_prefix = api_version.prefix();
let sub_path = sub_path.map(sanitize_shell_sub_path).transpose()?;
let base_path = match sub_path.as_deref() {
Some(sp) => format!("/{version_prefix}/contract/web/{contract_key}/{sp}"),
None => format!("/{version_prefix}/contract/web/{contract_key}/"),
};
let mut iframe_params = vec!["__sandbox=1".to_string()];
if let Some(qs) = &query_string {
for param in qs.split('&') {
if param.is_empty() {
continue;
}
if param.starts_with("__sandbox") || param.starts_with("authToken") {
continue;
}
iframe_params.push(param.to_string());
}
}
let iframe_src_raw = format!("{}?{}", base_path, iframe_params.join("&"));
let iframe_src = html_escape_attr(&iframe_src_raw);
let auth_token = auth_token.as_str();
let favicon = format!(
"data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 640 471'>\
<path d='{}' fill='%23007FFF' fill-rule='evenodd'/></svg>",
super::home_page::RABBIT_SVG_PATH,
);
let (user_token_script, bridge_call) = if hosted_mode {
(
format!("<script>\n{SHELL_USER_TOKEN_JS}\n</script>\n"),
format!("freenetBridge(\"{auth_token}\", __freenet_user_token, true);"),
)
} else {
(String::new(), format!("freenetBridge(\"{auth_token}\");"))
};
let (hosted_styles, hosted_bar) = if hosted_mode {
(
format!("\n<style>{HOSTED_BAR_STYLES}</style>"),
format!("{HOSTED_BAR_HTML}\n<script>{HOSTED_BAR_JS}</script>"),
)
} else {
(String::new(), String::new())
};
let html = format!(
include_str!("path_handlers/assets/shell.html"),
favicon = favicon,
hosted_styles = hosted_styles,
hosted_bar = hosted_bar,
iframe_src = iframe_src,
SHELL_BRIDGE_JS = SHELL_BRIDGE_JS,
user_token_script = user_token_script,
bridge_call = bridge_call,
);
Ok(Html(html))
}
#[instrument(level = "debug", skip(request_sender))]
pub(super) async fn serve_sandbox_content(
key: String,
api_version: ApiVersion,
sub_path: Option<&str>,
request_sender: HttpClientApiRequest,
) -> Result<impl IntoResponse, WebSocketApiError> {
let page = sub_path.unwrap_or("index.html");
debug!("serve_sandbox_content: serving iframe content for key: {key}, page: {page}");
let instance_id =
ContractInstanceId::from_bytes(&key).map_err(|err| WebSocketApiError::InvalidParam {
error_cause: format!("{err}"),
})?;
refresh_cache_if_due(instance_id, &request_sender).await?;
let path = contract_web_path(&instance_id);
if !path.exists() {
return Err(WebSocketApiError::NodeError {
error_cause: format!("Contract not cached yet: {key}"),
});
}
sandbox_content_body(&path, &key, api_version, page).await
}
async fn sandbox_content_body(
path: &Path,
contract_key: &str,
api_version: ApiVersion,
page: &str,
) -> Result<impl IntoResponse + use<>, WebSocketApiError> {
let normalized = Path::new(page);
for component in normalized.components() {
if matches!(
component,
std::path::Component::ParentDir | std::path::Component::RootDir
) {
return Err(WebSocketApiError::InvalidParam {
error_cause: "Path traversal not allowed".to_string(),
});
}
}
let mut web_path = path.join(page);
if web_path.is_dir() {
web_path = web_path.join("index.html");
}
let canonical_base = path
.canonicalize()
.map_err(|err| WebSocketApiError::NodeError {
error_cause: format!("{err}"),
})?;
let canonical_file = web_path
.canonicalize()
.map_err(|err| WebSocketApiError::NodeError {
error_cause: format!("Page not found: {page} ({err})"),
})?;
if !canonical_file.starts_with(&canonical_base) {
return Err(WebSocketApiError::InvalidParam {
error_cause: "Path traversal not allowed".to_string(),
});
}
let mut key_file =
File::open(&canonical_file)
.await
.map_err(|err| WebSocketApiError::NodeError {
error_cause: format!("{err}"),
})?;
let mut buf = vec![];
key_file
.read_to_end(&mut buf)
.await
.map_err(|err| WebSocketApiError::NodeError {
error_cause: format!("{err}"),
})?;
let mut body = String::from_utf8(buf).map_err(|err| WebSocketApiError::NodeError {
error_cause: format!("{err}"),
})?;
let version_prefix = api_version.prefix();
let prefix = format!("/{version_prefix}/contract/web/{contract_key}/");
body = body.replace("\"/./", &format!("\"{prefix}"));
body = body.replace("'/./", &format!("'{prefix}"));
let injected_scripts =
format!("<script>{WEBSOCKET_SHIM_JS}</script><script>{NAVIGATION_INTERCEPTOR_JS}</script>");
if let Some(pos) = body.find("</head>") {
body.insert_str(pos, &injected_scripts);
} else if let Some(pos) = body.find("<body") {
body.insert_str(pos, &injected_scripts);
} else {
body = format!("{injected_scripts}{body}");
}
Ok(Html(body))
}
const SHELL_USER_TOKEN_JS: &str = include_str!("path_handlers/assets/shell_user_token.js");
const HOSTED_BAR_STYLES: &str = include_str!("path_handlers/assets/hosted_bar.css");
const HOSTED_BAR_HTML: &str = include_str!("path_handlers/assets/hosted_bar.html");
const HOSTED_BAR_JS: &str = include_str!("path_handlers/assets/hosted_bar.js");
const SHELL_BRIDGE_JS: &str = include_str!("path_handlers/assets/shell_bridge.js");
const WEBSOCKET_SHIM_JS: &str = include_str!("path_handlers/assets/websocket_shim.js");
const NAVIGATION_INTERCEPTOR_JS: &str =
include_str!("path_handlers/assets/navigation_interceptor.js");
fn get_file_path(uri: axum::http::Uri) -> Result<String, Box<WebSocketApiError>> {
let path_str = uri.path();
let remainder = if let Some(rem) = path_str.strip_prefix("/v1/contract/web/") {
rem
} else if let Some(rem) = path_str.strip_prefix("/v1/contract/") {
rem
} else if let Some(rem) = path_str.strip_prefix("/v2/contract/web/") {
rem
} else if let Some(rem) = path_str.strip_prefix("/v2/contract/") {
rem
} else {
return Err(Box::new(WebSocketApiError::InvalidParam {
error_cause: format!(
"URI path '{path_str}' does not start with /v1/contract/ or /v2/contract/"
),
}));
};
let file_path = match remainder.split_once('/') {
Some((_key, path)) => path.to_string(),
None => "".to_string(),
};
Ok(file_path)
}
fn webapp_cache_dir() -> PathBuf {
directories::ProjectDirs::from("", "The Freenet Project Inc", "freenet")
.map(|dirs| dirs.cache_dir().to_path_buf())
.unwrap_or_else(|| std::env::temp_dir().join("freenet"))
.join("webapp_cache")
}
fn contract_web_path(instance_id: &ContractInstanceId) -> PathBuf {
webapp_cache_dir().join(instance_id.encode())
}
fn hash_state(state: &[u8]) -> u64 {
use std::hash::Hasher;
let mut hasher = ahash::AHasher::default();
hasher.write(state);
hasher.finish()
}
fn state_hash_path(instance_id: &ContractInstanceId) -> PathBuf {
webapp_cache_dir().join(format!("{}.hash", instance_id.encode()))
}
#[cfg(test)]
mod tests {
use super::*;
fn request_channel() -> (
HttpClientApiRequest,
tokio::sync::mpsc::Receiver<ClientConnection>,
) {
let (tx, rx) = tokio::sync::mpsc::channel::<ClientConnection>(4);
(HttpClientApiRequest::from_sender(tx), rx)
}
async fn clear_cache(instance_id: &ContractInstanceId) {
tokio::fs::remove_file(state_hash_path(instance_id))
.await
.ok();
tokio::fs::remove_dir_all(contract_web_path(instance_id))
.await
.ok();
CONTRACT_CACHE_REFRESH.remove(instance_id);
CONTRACT_REFRESH_LOCKS.remove(instance_id);
}
#[tokio::test]
async fn variable_content_triggers_fetch_on_cache_miss() {
let mut bytes = [0u8; 32];
bytes[0] = 0x3a;
bytes[1] = 0x40;
let instance_id = ContractInstanceId::new(bytes);
let key = instance_id.to_string();
clear_cache(&instance_id).await;
let (sender, mut rx) = request_channel();
let handler = {
let key = key.clone();
tokio::spawn(async move {
variable_content(
key.clone(),
format!("/v1/contract/web/{key}/image.jpg"),
ApiVersion::V1,
sender,
)
.await
.map(|_| ())
})
};
expect_fetch_pair_cold(&mut rx, instance_id).await;
handler.abort();
clear_cache(&instance_id).await;
}
#[tokio::test]
async fn variable_content_skips_fetch_for_unknown_instance() {
let mut bytes = [0u8; 32];
bytes[0] = 0x3a;
bytes[1] = 0x47;
let instance_id = ContractInstanceId::new(bytes);
let key = instance_id.to_string();
clear_cache(&instance_id).await;
let (sender, mut rx) = request_channel();
let handler = {
let key = key.clone();
tokio::spawn(async move {
variable_content(
key.clone(),
format!("/v1/contract/web/{key}/image.jpg"),
ApiVersion::V1,
sender,
)
.await
.map(|r| r.into_response())
})
};
answer_presence_query(&mut rx, instance_id, |_query_id| empty_diagnostics()).await;
let result = tokio::time::timeout(std::time::Duration::from_secs(5), handler)
.await
.expect("handler must finish without issuing a network fetch")
.expect("handler must not panic")
.expect("unknown-instance request must still resolve to a response");
assert_eq!(
result.status(),
axum::http::StatusCode::NOT_FOUND,
"an unknown cold-cache subresource must 404, not fetch"
);
let mut saw_fetch = false;
while let Ok(msg) = rx.try_recv() {
match msg {
ClientConnection::NewConnection { .. } => saw_fetch = true,
ClientConnection::Request { req, .. } => {
if matches!(
req.as_ref(),
ClientRequest::ContractOp(ContractRequest::Get { .. })
) {
saw_fetch = true;
}
}
}
}
assert!(
!saw_fetch,
"unknown-instance request must NOT issue a network fetch (#3945 DoS gate)"
);
clear_cache(&instance_id).await;
}
#[tokio::test(start_paused = true)]
async fn variable_content_fails_closed_when_presence_query_unanswered() {
let mut bytes = [0u8; 32];
bytes[0] = 0x3a;
bytes[1] = 0x48;
let instance_id = ContractInstanceId::new(bytes);
let key = instance_id.to_string();
clear_cache(&instance_id).await;
let (sender, mut rx) = request_channel();
let handler = {
let key = key.clone();
tokio::spawn(async move {
variable_content(
key.clone(),
format!("/v1/contract/web/{key}/image.jpg"),
ApiVersion::V1,
sender,
)
.await
.map(|r| r.into_response())
})
};
let new_conn = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
.await
.expect("handler must send NewConnection for the presence query")
.expect("channel must remain open");
let _callbacks = match new_conn {
ClientConnection::NewConnection { callbacks, .. } => {
callbacks
.send(HostCallbackResult::NewId {
id: crate::client_events::ClientId::next(),
})
.expect("callback receiver live for query NewId");
callbacks
}
other => panic!("presence query must open with NewConnection, got: {other:?}"),
};
let _query = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
.await
.expect("handler must send the NodeDiagnostics query")
.expect("channel must remain open");
tokio::time::advance(PRESENCE_QUERY_TIMEOUT + Duration::from_secs(1)).await;
let result = tokio::time::timeout(std::time::Duration::from_secs(5), handler)
.await
.expect("handler must finish once the presence query times out")
.expect("handler must not panic")
.expect("request must still resolve to a response");
assert_eq!(
result.status(),
axum::http::StatusCode::NOT_FOUND,
"an unanswered presence query must fail closed → 404, not fetch"
);
let mut saw_fetch = false;
while let Ok(msg) = rx.try_recv() {
match msg {
ClientConnection::NewConnection { .. } => saw_fetch = true,
ClientConnection::Request { req, .. } => {
if matches!(
req.as_ref(),
ClientRequest::ContractOp(ContractRequest::Get { .. })
) {
saw_fetch = true;
}
}
}
}
assert!(
!saw_fetch,
"a timed-out presence query must NOT issue a network fetch (#3945 fail-closed)"
);
clear_cache(&instance_id).await;
}
#[tokio::test(start_paused = true)]
async fn variable_content_fails_closed_when_newid_never_arrives() {
let mut bytes = [0u8; 32];
bytes[0] = 0x3a;
bytes[1] = 0x4c;
let instance_id = ContractInstanceId::new(bytes);
let key = instance_id.to_string();
clear_cache(&instance_id).await;
let (sender, mut rx) = request_channel();
let handler = {
let key = key.clone();
tokio::spawn(async move {
variable_content(
key.clone(),
format!("/v1/contract/web/{key}/image.jpg"),
ApiVersion::V1,
sender,
)
.await
.map(|r| r.into_response())
})
};
let new_conn = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
.await
.expect("handler must send NewConnection for the presence query")
.expect("channel must remain open");
let _callbacks = match new_conn {
ClientConnection::NewConnection { callbacks, .. } => callbacks,
other => panic!("presence query must open with NewConnection, got: {other:?}"),
};
tokio::time::advance(PRESENCE_QUERY_TIMEOUT + Duration::from_secs(1)).await;
let result = tokio::time::timeout(std::time::Duration::from_secs(5), handler)
.await
.expect("handler must finish once the NewId wait times out")
.expect("handler must not panic")
.expect("request must still resolve to a response");
assert_eq!(
result.status(),
axum::http::StatusCode::NOT_FOUND,
"a missing NewId must fail closed → 404, not fetch"
);
let mut saw_fetch = false;
while let Ok(msg) = rx.try_recv() {
match msg {
ClientConnection::NewConnection { .. } => saw_fetch = true,
ClientConnection::Request { req, .. } => {
if matches!(
req.as_ref(),
ClientRequest::ContractOp(ContractRequest::Get { .. })
) {
saw_fetch = true;
}
}
}
}
assert!(
!saw_fetch,
"a missing NewId must NOT issue a network fetch (#3945 fail-closed)"
);
clear_cache(&instance_id).await;
}
#[tokio::test]
async fn variable_content_fails_closed_when_node_channel_closed() {
let mut bytes = [0u8; 32];
bytes[0] = 0x3a;
bytes[1] = 0x49;
let instance_id = ContractInstanceId::new(bytes);
let key = instance_id.to_string();
clear_cache(&instance_id).await;
let (sender, rx) = request_channel();
drop(rx);
let result = variable_content(
key.clone(),
format!("/v1/contract/web/{key}/image.jpg"),
ApiVersion::V1,
sender,
)
.await
.map(|r| r.into_response());
let response = result.expect("closed-channel cold request must still resolve");
assert_eq!(
response.status(),
axum::http::StatusCode::NOT_FOUND,
"a closed node channel must fail closed → 404"
);
clear_cache(&instance_id).await;
}
#[tokio::test]
async fn variable_content_triggers_fetch_for_subscribed_not_stored() {
let mut bytes = [0u8; 32];
bytes[0] = 0x3a;
bytes[1] = 0x4a;
let instance_id = ContractInstanceId::new(bytes);
let key = instance_id.to_string();
clear_cache(&instance_id).await;
let (sender, mut rx) = request_channel();
let handler = {
let key = key.clone();
tokio::spawn(async move {
variable_content(
key.clone(),
format!("/v1/contract/web/{key}/image.jpg"),
ApiVersion::V1,
sender,
)
.await
.map(|_| ())
})
};
answer_presence_query(&mut rx, instance_id, |query_id| {
let mut diag = empty_diagnostics();
diag.subscriptions
.push(freenet_stdlib::client_api::SubscriptionInfo {
contract_key: instance_id,
client_id: query_id.into(),
});
diag
})
.await;
expect_fetch_pair(&mut rx, instance_id).await;
handler.abort();
clear_cache(&instance_id).await;
}
#[tokio::test]
async fn warm_but_stale_refreshes_without_presence_gate() {
let mut bytes = [0u8; 32];
bytes[0] = 0x3a;
bytes[1] = 0x4b;
let instance_id = ContractInstanceId::new(bytes);
clear_cache(&instance_id).await;
let cache_dir = contract_web_path(&instance_id);
tokio::fs::create_dir_all(&cache_dir).await.unwrap();
tokio::fs::write(state_hash_path(&instance_id), 0u64.to_be_bytes())
.await
.unwrap();
let (sender, mut rx) = request_channel();
let handler =
tokio::spawn(
async move { refresh_cache_if_due(instance_id, &sender).await.map(|_| ()) },
);
expect_fetch_pair(&mut rx, instance_id).await;
handler.abort();
clear_cache(&instance_id).await;
}
#[tokio::test]
async fn variable_content_skips_fetch_when_cache_present_and_fresh() {
let mut bytes = [0u8; 32];
bytes[0] = 0x3a;
bytes[1] = 0x41;
let instance_id = ContractInstanceId::new(bytes);
let key = instance_id.to_string();
clear_cache(&instance_id).await;
let cache_dir = contract_web_path(&instance_id);
tokio::fs::create_dir_all(&cache_dir).await.unwrap();
tokio::fs::write(cache_dir.join("image.jpg"), b"fake-jpeg-bytes")
.await
.unwrap();
tokio::fs::write(state_hash_path(&instance_id), 0u64.to_be_bytes())
.await
.unwrap();
CONTRACT_CACHE_REFRESH.insert(instance_id, Instant::now());
let (sender, mut rx) = request_channel();
let result = variable_content(
key.clone(),
format!("/v1/contract/web/{key}/image.jpg"),
ApiVersion::V1,
sender,
)
.await;
let response = result.expect("warm-cache request must succeed");
let body = response_body(response).await;
assert_eq!(
body, "fake-jpeg-bytes",
"warm-cache path must serve the primed file byte-for-byte"
);
assert!(
rx.try_recv().is_err(),
"fresh-cache path must not send any NewConnection/Get on the channel"
);
clear_cache(&instance_id).await;
}
async fn answer_presence_query(
rx: &mut tokio::sync::mpsc::Receiver<ClientConnection>,
instance_id: ContractInstanceId,
build_reply: impl FnOnce(
crate::client_events::ClientId,
) -> freenet_stdlib::client_api::NodeDiagnosticsResponse,
) {
use freenet_stdlib::client_api::{NodeQuery, QueryResponse};
let new_conn = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
.await
.expect("handler must send NewConnection for the local-known query")
.expect("channel must remain open");
let callbacks = match new_conn {
ClientConnection::NewConnection { callbacks, .. } => callbacks,
other => panic!("local-known query must open with NewConnection, got: {other:?}"),
};
callbacks
.send(HostCallbackResult::NewId {
id: crate::client_events::ClientId::next(),
})
.expect("callback receiver live for query NewId");
let query = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
.await
.expect("handler must send the presence query")
.expect("channel must remain open");
let ClientConnection::Request { req, client_id, .. } = query else {
panic!("expected the NodeDiagnostics request, got: {query:?}");
};
if let ClientRequest::NodeQueries(NodeQuery::NodeDiagnostics { config }) = req.as_ref() {
assert_eq!(
config.contract_keys.len(),
1,
"presence query must request exactly one contract key"
);
assert_eq!(
*config.contract_keys[0].id(),
instance_id,
"presence query must be scoped to the requested instance"
);
assert!(
!config.include_node_info
&& !config.include_network_info
&& !config.include_system_metrics
&& !config.include_detailed_peer_info,
"presence query must keep the heavy diagnostics flags off"
);
} else {
panic!("local-known query must be NodeQueries(NodeDiagnostics), got: {req:?}");
}
let query_id = client_id;
callbacks
.send(HostCallbackResult::Result {
id: query_id,
result: Ok(HostResponse::QueryResponse(QueryResponse::NodeDiagnostics(
build_reply(query_id),
))),
})
.expect("callback receiver live for NodeDiagnostics reply");
let _ = rx.recv().await;
}
fn empty_diagnostics() -> freenet_stdlib::client_api::NodeDiagnosticsResponse {
freenet_stdlib::client_api::NodeDiagnosticsResponse {
node_info: None,
network_info: None,
subscriptions: Vec::new(),
contract_states: std::collections::HashMap::new(),
system_metrics: None,
connected_peers_detailed: Vec::new(),
}
}
async fn answer_presence_query_hosted(
rx: &mut tokio::sync::mpsc::Receiver<ClientConnection>,
instance_id: ContractInstanceId,
) {
answer_presence_query(rx, instance_id, |_query_id| {
let mut diag = empty_diagnostics();
diag.contract_states.insert(
instance_id.to_string(),
freenet_stdlib::client_api::ContractState {
subscribers: 0,
subscriber_peer_ids: Vec::new(),
size_bytes: 1234,
},
);
diag
})
.await;
}
async fn expect_fetch_pair(
rx: &mut tokio::sync::mpsc::Receiver<ClientConnection>,
instance_id: ContractInstanceId,
) {
let new_conn = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
.await
.expect("handler must send NewConnection when a refresh is due")
.expect("channel must remain open for the duration of the send");
let callbacks = match new_conn {
ClientConnection::NewConnection { callbacks, .. } => callbacks,
other => panic!("first message must be NewConnection, got: {other:?}"),
};
callbacks
.send(HostCallbackResult::NewId {
id: crate::client_events::ClientId::next(),
})
.expect("callback receiver must be live while handler awaits NewId");
let get_req = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
.await
.expect("handler must follow up with a Get request")
.expect("channel must remain open");
match get_req {
ClientConnection::Request { req, .. } => {
assert!(
matches!(
req.as_ref(),
ClientRequest::ContractOp(ContractRequest::Get { key: k, .. })
if *k == instance_id
),
"second message must be Get({instance_id}), got: {req:?}"
);
}
other => panic!("expected ClientConnection::Request, got: {other:?}"),
}
}
async fn expect_fetch_pair_cold(
rx: &mut tokio::sync::mpsc::Receiver<ClientConnection>,
instance_id: ContractInstanceId,
) {
answer_presence_query_hosted(rx, instance_id).await;
expect_fetch_pair(rx, instance_id).await;
}
#[tokio::test]
async fn serve_sandbox_content_triggers_refresh_when_stale() {
let mut bytes = [0u8; 32];
bytes[0] = 0x3a;
bytes[1] = 0x44;
let instance_id = ContractInstanceId::new(bytes);
let key = instance_id.to_string();
clear_cache(&instance_id).await;
let cache_dir = contract_web_path(&instance_id);
tokio::fs::create_dir_all(&cache_dir).await.unwrap();
tokio::fs::write(cache_dir.join("index.html"), b"<html>old bundle</html>")
.await
.unwrap();
tokio::fs::write(state_hash_path(&instance_id), 0u64.to_be_bytes())
.await
.unwrap();
let (sender, mut rx) = request_channel();
let handler = {
let key = key.clone();
tokio::spawn(async move {
serve_sandbox_content(key.clone(), ApiVersion::V1, None, sender)
.await
.map(|_| ())
})
};
expect_fetch_pair(&mut rx, instance_id).await;
handler.abort();
clear_cache(&instance_id).await;
}
#[tokio::test]
async fn serve_sandbox_content_skips_refresh_when_fresh() {
let mut bytes = [0u8; 32];
bytes[0] = 0x3a;
bytes[1] = 0x45;
let instance_id = ContractInstanceId::new(bytes);
let key = instance_id.to_string();
clear_cache(&instance_id).await;
let cache_dir = contract_web_path(&instance_id);
tokio::fs::create_dir_all(&cache_dir).await.unwrap();
tokio::fs::write(cache_dir.join("index.html"), b"<html>fresh bundle</html>")
.await
.unwrap();
tokio::fs::write(state_hash_path(&instance_id), 0u64.to_be_bytes())
.await
.unwrap();
CONTRACT_CACHE_REFRESH.insert(instance_id, Instant::now());
let (sender, mut rx) = request_channel();
let result = serve_sandbox_content(key.clone(), ApiVersion::V1, None, sender).await;
let response = result.expect("fresh-cache sandbox request must succeed");
let body = response_body(response).await;
assert!(
body.contains("fresh bundle"),
"fresh-cache path must serve the primed index.html, got: {body}"
);
assert!(
rx.try_recv().is_err(),
"fresh-cache sandbox path must not send any NewConnection/Get on the channel"
);
clear_cache(&instance_id).await;
}
#[tokio::test(start_paused = true)]
async fn refresh_cache_if_due_refetches_after_ttl_expires() {
let mut bytes = [0u8; 32];
bytes[0] = 0x3a;
bytes[1] = 0x46;
let instance_id = ContractInstanceId::new(bytes);
clear_cache(&instance_id).await;
let cache_dir = contract_web_path(&instance_id);
tokio::fs::create_dir_all(&cache_dir).await.unwrap();
tokio::fs::write(state_hash_path(&instance_id), 0u64.to_be_bytes())
.await
.unwrap();
CONTRACT_CACHE_REFRESH.insert(instance_id, Instant::now());
tokio::time::advance(CONTRACT_CACHE_REFRESH_TTL + Duration::from_secs(1)).await;
let (sender, mut rx) = request_channel();
let handler =
tokio::spawn(
async move { refresh_cache_if_due(instance_id, &sender).await.map(|_| ()) },
);
expect_fetch_pair(&mut rx, instance_id).await;
handler.abort();
clear_cache(&instance_id).await;
}
async fn serve_one_get(
rx: &mut tokio::sync::mpsc::Receiver<ClientConnection>,
contract: &ContractContainer,
state: &WrappedState,
) {
let msg = rx.recv().await.expect("leader must issue NewConnection");
let callbacks = match msg {
ClientConnection::NewConnection { callbacks, .. } => callbacks,
other => panic!("expected NewConnection, got: {other:?}"),
};
callbacks
.send(HostCallbackResult::NewId {
id: crate::client_events::ClientId::next(),
})
.expect("callback receiver live");
let get = rx.recv().await.expect("Get must follow NewConnection");
match get {
ClientConnection::Request { req, .. } => assert!(
matches!(
req.as_ref(),
ClientRequest::ContractOp(ContractRequest::Get { .. })
),
"expected Get, got: {req:?}"
),
other => panic!("expected Get request, got: {other:?}"),
}
callbacks
.send(HostCallbackResult::Result {
id: crate::client_events::ClientId::next(),
result: Ok(HostResponse::ContractResponse(
ContractResponse::GetResponse {
key: contract.key(),
contract: Some(contract.clone()),
state: state.clone(),
},
)),
})
.expect("callback receiver live for GetResponse");
let _ = rx.recv().await;
}
#[tokio::test]
async fn refresh_cache_if_due_coalesces_concurrent_refreshes() {
let contract = ContractContainer::Wasm(ContractWasmAPIVersion::V1(WrappedContract::new(
Arc::new(ContractCode::from(vec![1, 2, 3, 4])),
Parameters::from(vec![5, 6]),
)));
let instance_id = *contract.key().id();
let state = WrappedState::new(vec![9, 9, 9]);
clear_cache(&instance_id).await;
let cache_dir = contract_web_path(&instance_id);
tokio::fs::create_dir_all(&cache_dir).await.unwrap();
let matching_hash = hash_state(state.as_ref());
tokio::fs::write(state_hash_path(&instance_id), matching_hash.to_be_bytes())
.await
.unwrap();
let (sender, mut rx) = request_channel();
let mut handlers = Vec::new();
for _ in 0..8 {
let sender = sender.clone();
handlers.push(tokio::spawn(async move {
refresh_cache_if_due(instance_id, &sender).await.map(|_| ())
}));
}
drop(sender);
serve_one_get(&mut rx, &contract, &state).await;
let mut extra = 0;
while let Some(msg) = rx.recv().await {
if matches!(msg, ClientConnection::NewConnection { .. }) {
extra += 1;
}
}
assert_eq!(
extra, 0,
"concurrent refreshers must coalesce to a single GET; saw {extra} extra"
);
for h in handlers {
h.await
.expect("handler must not panic")
.expect("refresh must succeed");
}
clear_cache(&instance_id).await;
}
#[tokio::test]
async fn refresh_cache_if_due_does_not_record_timer_on_fetch_failure() {
let contract = ContractContainer::Wasm(ContractWasmAPIVersion::V1(WrappedContract::new(
Arc::new(ContractCode::from(vec![7, 7, 7, 7])),
Parameters::from(vec![8, 8]),
)));
let instance_id = *contract.key().id();
clear_cache(&instance_id).await;
let cache_dir = contract_web_path(&instance_id);
tokio::fs::create_dir_all(&cache_dir).await.unwrap();
tokio::fs::write(state_hash_path(&instance_id), 0u64.to_be_bytes())
.await
.unwrap();
let (sender, mut rx) = request_channel();
let handler = tokio::spawn(async move { refresh_cache_if_due(instance_id, &sender).await });
let msg = rx.recv().await.expect("must issue NewConnection");
let callbacks = match msg {
ClientConnection::NewConnection { callbacks, .. } => callbacks,
other => panic!("expected NewConnection, got: {other:?}"),
};
callbacks
.send(HostCallbackResult::NewId {
id: crate::client_events::ClientId::next(),
})
.expect("callback receiver live");
let _get = rx.recv().await.expect("Get must follow NewConnection");
callbacks
.send(HostCallbackResult::Result {
id: crate::client_events::ClientId::next(),
result: Ok(HostResponse::ContractResponse(
ContractResponse::GetResponse {
key: contract.key(),
contract: None,
state: WrappedState::new(Vec::new()),
},
)),
})
.expect("callback receiver live for GetResponse");
let result = tokio::time::timeout(std::time::Duration::from_secs(5), handler)
.await
.expect("handler must finish promptly")
.expect("handler must not panic");
assert!(
result.is_err(),
"a None-contract GetResponse must surface as an error, got: {result:?}"
);
assert!(
!CONTRACT_CACHE_REFRESH.contains_key(&instance_id),
"a failed refresh must NOT record a timer, or the next request would \
be suppressed for the whole TTL instead of retrying"
);
clear_cache(&instance_id).await;
}
#[tokio::test]
async fn handle_get_response_maps_none_contract_to_missing_contract_error() {
let mut bytes = [0u8; 32];
bytes[0] = 0x3a;
bytes[1] = 0x42;
let instance_id = ContractInstanceId::new(bytes);
let key = freenet_stdlib::prelude::ContractKey::from_id_and_code(
instance_id,
freenet_stdlib::prelude::CodeHash::new([0u8; 32]),
);
let result = handle_get_response(
instance_id,
Ok(Some(HostCallbackResult::Result {
id: crate::client_events::ClientId::next(),
result: Ok(HostResponse::ContractResponse(
ContractResponse::GetResponse {
key,
contract: None,
state: WrappedState::new(Vec::new()),
},
)),
})),
)
.await;
assert!(
matches!(
result,
Err(WebSocketApiError::MissingContract { instance_id: id }) if id == instance_id
),
"None-contract GetResponse must surface as MissingContract({instance_id}), got: {result:?}"
);
}
#[tokio::test]
async fn handle_get_response_maps_timeout_to_node_error() {
let mut bytes = [0u8; 32];
bytes[0] = 0x3a;
bytes[1] = 0x43;
let instance_id = ContractInstanceId::new(bytes);
let elapsed = tokio::time::timeout(
std::time::Duration::from_millis(0),
std::future::pending::<()>(),
)
.await
.expect_err("timeout must fire");
let recv_result: Result<Option<HostCallbackResult>, _> = Err(elapsed);
let result = handle_get_response(instance_id, recv_result).await;
assert!(
matches!(result, Err(WebSocketApiError::NodeError { .. })),
"30s timeout must map to NodeError, got: {result:?}"
);
}
async fn response_body(resp: impl IntoResponse) -> String {
let body = resp.into_response();
let bytes = axum::body::to_bytes(body.into_body(), 1024 * 1024)
.await
.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
}
#[tokio::test]
async fn root_relative_asset_paths_rewritten() {
let dir = tempfile::tempdir().unwrap();
let key = "raAqMhMG7KUpXBU2SxgCQ3Vh4PYjttxdSWd9ftV7RLv";
let html = r#"<!DOCTYPE html>
<html>
<head>
<title>Test</title>
<link rel="preload" as="script" href="/./assets/app.js" crossorigin></head>
<body><div id="main"></div>
<script type="module" async src="/./assets/app.js"></script>
</body>
</html>"#;
std::fs::write(dir.path().join("index.html"), html).unwrap();
let result = response_body(
sandbox_content_body(dir.path(), key, ApiVersion::V1, "index.html")
.await
.unwrap(),
)
.await;
let expected_href = format!("href=\"/v1/contract/web/{key}/assets/app.js\"");
assert!(
result.contains(&expected_href),
"href not rewritten.\nGot: {result}"
);
let expected_src = format!("src=\"/v1/contract/web/{key}/assets/app.js\"");
assert!(
result.contains(&expected_src),
"src not rewritten.\nGot: {result}"
);
assert!(
!result.contains("\"/./assets/"),
"original /./assets/ paths still present"
);
assert!(
result.contains("FreenetWebSocket"),
"WebSocket shim not injected"
);
}
#[tokio::test]
async fn root_relative_asset_paths_rewritten_v2() {
let dir = tempfile::tempdir().unwrap();
let key = "raAqMhMG7KUpXBU2SxgCQ3Vh4PYjttxdSWd9ftV7RLv";
let html = r#"<head><link href="/./assets/app.js"></head><body></body>"#;
std::fs::write(dir.path().join("index.html"), html).unwrap();
let result = response_body(
sandbox_content_body(dir.path(), key, ApiVersion::V2, "index.html")
.await
.unwrap(),
)
.await;
let expected = format!("href=\"/v2/contract/web/{key}/assets/app.js\"");
assert!(
result.contains(&expected),
"V2 href not rewritten.\nGot: {result}"
);
assert!(
!result.contains("\"/./assets/"),
"original /./assets/ paths still present in V2"
);
}
#[tokio::test]
async fn single_quoted_paths_also_rewritten() {
let dir = tempfile::tempdir().unwrap();
let key = "testkey123";
let html = "<head><script src='/./assets/app.js'></script></head>";
std::fs::write(dir.path().join("index.html"), html).unwrap();
let result = response_body(
sandbox_content_body(dir.path(), key, ApiVersion::V1, "index.html")
.await
.unwrap(),
)
.await;
let expected = format!("'/v1/contract/web/{key}/assets/app.js'");
assert!(
result.contains(&expected),
"single-quoted path not rewritten.\nGot: {result}"
);
}
#[tokio::test]
async fn paths_without_dot_slash_not_rewritten() {
let dir = tempfile::tempdir().unwrap();
let key = "testkey123";
let html = r#"<head><link href="/assets/app.css"></head><body></body>"#;
std::fs::write(dir.path().join("index.html"), html).unwrap();
let result = response_body(
sandbox_content_body(dir.path(), key, ApiVersion::V1, "index.html")
.await
.unwrap(),
)
.await;
assert!(
result.contains("\"/assets/app.css\""),
"path without /. was incorrectly rewritten.\nGot: {result}"
);
}
#[tokio::test]
async fn shell_page_iframe_sandbox_allows_downloads() {
let token = AuthToken::generate();
let html = response_body(
shell_page(&token, "testkey123", ApiVersion::V1, None, None, false).unwrap(),
)
.await;
assert!(
html.contains("allow-downloads"),
"iframe sandbox missing `allow-downloads` — user-initiated \
file downloads from sandboxed webapps will be silently blocked \
by the browser. Got HTML:\n{html}"
);
}
#[tokio::test]
async fn shell_page_hosted_mode_renders_proxy_chrome_bar() {
let token = AuthToken::generate();
let hosted = response_body(
shell_page(&token, "testkey123", ApiVersion::V1, None, None, true).unwrap(),
)
.await;
assert!(
hosted.contains(r#"id="fnbar""#),
"hosted bar missing: {hosted}"
);
assert!(
hosted.contains("not private"),
"always-visible disclosure missing"
);
assert!(
hosted.contains("Access key") && hosted.contains("Restore from key"),
"access-key backup/restore controls missing"
);
assert!(hosted.contains("Export data"), "export control missing");
assert!(
hosted.contains("__freenet_user_token"),
"access-key source global missing"
);
let plain = response_body(
shell_page(&token, "testkey123", ApiVersion::V1, None, None, false).unwrap(),
)
.await;
assert!(
!plain.contains(r#"id="fnbar""#),
"non-hosted shell must not render the proxy chrome bar"
);
assert!(
!plain.contains("Export data"),
"non-hosted shell must not render the export control"
);
}
#[tokio::test]
async fn shell_page_contains_iframe_and_bridge() {
let token = AuthToken::generate();
let html = response_body(
shell_page(&token, "testkey123", ApiVersion::V1, None, None, false).unwrap(),
)
.await;
assert!(
html.contains(
r#"sandbox="allow-scripts allow-forms allow-popups allow-downloads allow-modals""#
),
"iframe sandbox attribute missing or wrong allowlist"
);
assert!(
html.contains(r#"allow="clipboard-read; clipboard-write""#),
"iframe permissions-policy missing clipboard grants"
);
assert!(
html.contains("__sandbox=1"),
"iframe src missing __sandbox=1 param"
);
assert!(
html.contains("freenetBridge"),
"bridge script not found in shell page"
);
assert!(
!html.contains("__FREENET_AUTH_TOKEN__"),
"auth token exposed in global variable (security risk)"
);
assert!(
html.contains(&format!("freenetBridge(\"{}\")", token.as_str())),
"auth token not passed to bridge"
);
assert!(
html.contains("<title>Freenet</title>"),
"shell page title mismatch"
);
assert!(
html.contains(r#"<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,"#),
"favicon should use inline data URI, not external URL"
);
assert!(
!html.contains("freenet.org"),
"shell page must not reference external origins (CORS)"
);
assert!(
html.contains("__freenet_shell__"),
"bridge JS must handle shell-level messages (title/favicon)"
);
assert!(
!html.contains("allow-popups-to-escape-sandbox"),
"allow-popups-to-escape-sandbox must not be set (security: #1499)"
);
assert!(
html.contains("open_url"),
"shell bridge must handle open_url messages for external links"
);
}
#[tokio::test]
async fn shell_page_permission_overlay_present_and_safe() {
let token = AuthToken::generate();
let html = response_body(
shell_page(&token, "testkey123", ApiVersion::V1, None, None, false).unwrap(),
)
.await;
assert!(
html.contains("__freenet_perm_overlay"),
"permission overlay root element missing from shell JS"
);
assert!(
html.contains("'role', 'dialog'") || html.contains("\"role\", \"dialog\""),
"overlay must declare role=dialog for a11y"
);
assert!(html.contains("aria-modal"), "overlay must set aria-modal");
assert!(
html.contains("/permission/events"),
"shell JS must subscribe to /permission/events (SSE)"
);
assert!(
html.contains("/permission/pending"),
"shell JS must reference /permission/pending for bootstrap/resync"
);
assert!(
html.contains("/respond"),
"shell JS must POST to /permission/{{nonce}}/respond"
);
assert!(
html.contains("r.status === 404"),
"shell JS must treat 404 on respond as 'already answered' and hide the card"
);
assert!(
html.contains("'prompt_added'") || html.contains("\"prompt_added\""),
"shell JS must subscribe to the prompt_added SSE event"
);
assert!(
html.contains("'prompt_removed'") || html.contains("\"prompt_removed\""),
"shell JS must subscribe to the prompt_removed SSE event"
);
assert!(
html.contains("function setText(el, text)"),
"setText helper (textContent-only) missing"
);
let overlay_start = html.find("__freenet_perm_overlay").unwrap();
let overlay_end = html[overlay_start..]
.find("setInterval(reconcileFromPending")
.or_else(|| html[overlay_start..].find("EventSource"))
.expect("overlay slice must end at the SSE setup or fallback poll");
let overlay_slice = &html[overlay_start..overlay_start + overlay_end];
assert!(
!overlay_slice.contains("innerHTML"),
"overlay code path must not use innerHTML (XSS surface)"
);
assert!(
!html.contains("Notification.requestPermission"),
"browser Notification permission request must be removed (#3836)"
);
assert!(
!html.contains("new Notification("),
"browser Notification construction must be removed (#3836)"
);
assert!(
!html.contains("window.open('/permission/")
&& !html.contains("window.open(\"/permission/"),
"shell must no longer open /permission/{{nonce}} as a popup (#3836)"
);
assert!(
!html.contains("visibilityState"),
"overlay must not gate on document.visibilityState; \
visibility-skip caused background tabs to miss prompts (SSE replaces polling)"
);
assert!(
html.contains("'Delegate says:'") || html.contains("\"Delegate says:\""),
"shell overlay must render the 'Delegate says:' authorship label (#3857)"
);
assert!(
html.contains("function truncateHash("),
"shell overlay must define a truncateHash helper for the new disclosure (#3857)"
);
assert!(
html.contains("function formatCaller("),
"shell overlay must define a formatCaller helper for the tagged caller object (#3857)"
);
assert!(
html.contains("p.caller"),
"shell overlay must read p.caller from /permission/pending (#3857)"
);
assert!(
!html.contains("p.contract_id"),
"shell overlay must not read the removed p.contract_id field (#3857)"
);
assert!(
!html.contains("'fn-ctx'") && !html.contains("\"fn-ctx\""),
"shell overlay must not build the removed <dl class=\"fn-ctx\"> container (#3857)"
);
assert!(
html.contains("'Freenet app '") || html.contains("\"Freenet app \""),
"formatCaller must render webapp callers as 'Freenet app <hash>' (#3857)"
);
assert!(
html.contains("'No app caller'") || html.contains("\"No app caller\""),
"formatCaller must render the None / no-app case as 'No app caller' (#3857)"
);
assert!(
html.contains("'Unknown caller'") || html.contains("\"Unknown caller\""),
"formatCaller must have a forward-compatible fallback for unknown caller kinds (#3857)"
);
assert!(
html.contains("'Technical details'") || html.contains("\"Technical details\""),
"shell overlay must include a 'Technical details' disclosure (#3857)"
);
assert!(
html.contains("'fn-delegate-line'") || html.contains("\"fn-delegate-line\""),
"shell overlay must render the inline truncated delegate hash line (#3857)"
);
}
#[tokio::test]
async fn shell_page_iframe_uses_data_src_for_deep_linking() {
let token = AuthToken::generate();
let html = response_body(
shell_page(&token, "testkey123", ApiVersion::V1, None, None, false).unwrap(),
)
.await;
assert!(
!html.contains(
r#"<iframe id="app" sandbox="allow-scripts allow-forms allow-popups allow-downloads" src="#
),
"iframe must use data-src, not src, to avoid loading before JS appends the hash"
);
assert!(
html.contains("data-src=\"/"),
"iframe must have data-src attribute for JS to read"
);
}
#[tokio::test]
async fn shell_page_forwards_query_params_to_iframe() {
let token = AuthToken::generate();
let qs = Some("invitation=abc123&room=test".to_string());
let html = response_body(
shell_page(&token, "testkey123", ApiVersion::V1, qs, None, false).unwrap(),
)
.await;
assert!(
html.contains("invitation=abc123"),
"invitation param not forwarded to iframe"
);
assert!(
html.contains("room=test"),
"room param not forwarded to iframe"
);
assert!(
html.contains("?__sandbox=1&"),
"__sandbox=1 not first in iframe params"
);
}
#[tokio::test]
async fn shell_page_embeds_sub_path_in_iframe_data_src() {
let token = AuthToken::generate();
let html = response_body(
shell_page(
&token,
"testkey123",
ApiVersion::V1,
None,
Some("news/"),
false,
)
.unwrap(),
)
.await;
assert!(
html.contains(r#"data-src="/v1/contract/web/testkey123/news/?__sandbox=1""#),
"iframe data-src must carry the sub-path; got: {html}"
);
let html = response_body(
shell_page(
&token,
"testkey123",
ApiVersion::V1,
None,
Some("about/team"),
false,
)
.unwrap(),
)
.await;
assert!(
html.contains(r#"data-src="/v1/contract/web/testkey123/about/team?__sandbox=1""#),
"iframe data-src must carry the nested sub-path; got: {html}"
);
let html = response_body(
shell_page(&token, "testkey123", ApiVersion::V1, None, None, false).unwrap(),
)
.await;
assert!(
html.contains(r#"data-src="/v1/contract/web/testkey123/?__sandbox=1""#),
"root load must still point the iframe at the contract root; got: {html}"
);
}
#[test]
fn sanitize_shell_sub_path_accepts_safe_paths_and_rejects_dangerous() {
for ok in ["news/", "about/team", "page2", "index.html", "a/b/c/"] {
assert_eq!(
sanitize_shell_sub_path(ok).unwrap(),
ok,
"{ok} must be accepted unchanged"
);
}
for traversal in ["..", "../other", "a/../b", "a/..", "a/./b", "."] {
assert!(
matches!(
sanitize_shell_sub_path(traversal),
Err(WebSocketApiError::InvalidParam { .. })
),
"{traversal:?} (dot-segment) must be rejected"
);
}
for bad in [
"/absolute", "news/?evil=1", "news/#frag", "a b", "x\r\nInjected: y", "back\\slash", "tab\tafter", ] {
assert!(
matches!(
sanitize_shell_sub_path(bad),
Err(WebSocketApiError::InvalidParam { .. })
),
"{bad:?} must be rejected"
);
}
}
#[tokio::test]
async fn contract_home_with_sub_path_renders_shell_for_that_page() {
let contract = ContractContainer::Wasm(ContractWasmAPIVersion::V1(WrappedContract::new(
Arc::new(ContractCode::from(vec![3, 1, 8, 4, 1])),
Parameters::from(vec![3, 8, 4, 1]),
)));
let instance_id = *contract.key().id();
let key = instance_id.to_string();
let state = WrappedState::new(vec![4, 2]);
clear_cache(&instance_id).await;
let cache_dir = contract_web_path(&instance_id);
tokio::fs::create_dir_all(&cache_dir).await.unwrap();
let matching_hash = hash_state(state.as_ref());
tokio::fs::write(state_hash_path(&instance_id), matching_hash.to_be_bytes())
.await
.unwrap();
let (sender, mut rx) = request_channel();
let token = AuthToken::generate();
let handler = {
let key = key.clone();
tokio::spawn(async move {
contract_home(
key,
sender,
token,
ApiVersion::V1,
None,
Some("news/"),
false,
)
.await
.map(|resp| resp.into_response())
})
};
serve_one_get(&mut rx, &contract, &state).await;
let resp = handler
.await
.expect("contract_home task must not panic")
.expect("contract_home must succeed once the GET is served");
let html = response_body(resp).await;
assert!(
html.contains(&format!(
r#"data-src="/v1/contract/web/{key}/news/?__sandbox=1""#
)),
"deep-link shell iframe must load the sub-page; got: {html}"
);
clear_cache(&instance_id).await;
}
#[tokio::test]
async fn sandbox_content_injects_shims_not_auth_token() {
let dir = tempfile::tempdir().unwrap();
let key = "testkey123";
let html = r#"<!DOCTYPE html><html><head></head><body>Hello</body></html>"#;
std::fs::write(dir.path().join("index.html"), html).unwrap();
let result = response_body(
sandbox_content_body(dir.path(), key, ApiVersion::V1, "index.html")
.await
.unwrap(),
)
.await;
assert!(
result.contains("FreenetWebSocket"),
"WebSocket shim not injected"
);
assert!(
result.contains("window.WebSocket = FreenetWebSocket"),
"WebSocket override not set"
);
assert!(
result.contains("type: 'navigate'"),
"navigation interceptor not injected"
);
assert!(
!result.contains("__FREENET_AUTH_TOKEN__"),
"auth token leaked into sandbox content"
);
}
#[tokio::test]
async fn ws_shim_injected_without_head_tag() {
let dir = tempfile::tempdir().unwrap();
let key = "testkey123";
let html = "<body><div>Hello</div></body>";
std::fs::write(dir.path().join("index.html"), html).unwrap();
let result = response_body(
sandbox_content_body(dir.path(), key, ApiVersion::V1, "index.html")
.await
.unwrap(),
)
.await;
assert!(
result.contains("FreenetWebSocket"),
"WebSocket shim not injected when no </head> tag"
);
let shim_pos = result.find("FreenetWebSocket").unwrap();
let body_pos = result.find("<body").unwrap();
assert!(
shim_pos < body_pos,
"shim should be injected before <body> tag"
);
}
#[tokio::test]
async fn ws_shim_injected_in_minimal_html() {
let dir = tempfile::tempdir().unwrap();
let key = "testkey123";
let html = "<div>Hello World</div>";
std::fs::write(dir.path().join("index.html"), html).unwrap();
let result = response_body(
sandbox_content_body(dir.path(), key, ApiVersion::V1, "index.html")
.await
.unwrap(),
)
.await;
assert!(
result.contains("FreenetWebSocket"),
"WebSocket shim not injected in minimal HTML"
);
assert!(
result.starts_with("<script>"),
"shim should be prepended to content when no head/body tags"
);
}
#[tokio::test]
async fn shell_page_strips_sandbox_prefixed_params() {
let token = AuthToken::generate();
let qs = Some("__sandbox_extra=evil&invitation=abc&__sandboxFoo=bar".to_string());
let html = response_body(
shell_page(&token, "testkey123", ApiVersion::V1, qs, None, false).unwrap(),
)
.await;
assert!(
!html.contains("__sandbox_extra"),
"__sandbox_extra param should be stripped"
);
assert!(
!html.contains("__sandboxFoo"),
"__sandboxFoo param should be stripped"
);
assert!(
html.contains("invitation=abc"),
"normal param should be forwarded"
);
}
#[tokio::test]
async fn shell_page_strips_auth_token_from_forwarded_query() {
let token = AuthToken::generate();
let qs = Some("authToken=attacker_value&invite=abc&authTokenExtra=x".to_string());
let html = response_body(
shell_page(&token, "testkey123", ApiVersion::V1, qs, None, false).unwrap(),
)
.await;
assert!(
!html.contains("attacker_value"),
"attacker-supplied authToken value must not reach iframe src"
);
assert!(
!html.contains("authTokenExtra"),
"authToken-prefixed params must also be stripped"
);
assert!(
html.contains("invite=abc"),
"harmless params must still be forwarded"
);
assert!(
html.contains(&format!("freenetBridge(\"{}\"", token.as_str())),
"shell must still bind the freshly-generated auth token"
);
}
#[tokio::test]
async fn shell_page_escapes_html_in_query_params() {
let token = AuthToken::generate();
let qs = Some("foo=\"><script>alert(1)</script>".to_string());
let html = response_body(
shell_page(&token, "testkey123", ApiVersion::V1, qs, None, false).unwrap(),
)
.await;
assert!(
!html.contains("\"><script>alert"),
"unescaped HTML injection in iframe src"
);
assert!(
html.contains("""),
"double quote should be HTML-escaped"
);
}
#[tokio::test]
async fn shell_page_hosted_mode_injects_user_token() {
let token = AuthToken::generate();
let html = response_body(
shell_page(&token, "testkey123", ApiVersion::V1, None, None, true).unwrap(),
)
.await;
assert!(
html.contains("__freenet_user_token__"),
"hosted-mode shell must include the durable localStorage token key; got: {html}"
);
assert!(
html.contains("crypto.getRandomValues"),
"hosted-mode token must be minted from crypto.getRandomValues, not request input"
);
assert!(
html.contains("localStorage.setItem"),
"hosted-mode token must be persisted to localStorage"
);
assert!(
html.contains(&format!(
"freenetBridge(\"{}\", __freenet_user_token, true);",
token.as_str()
)),
"hosted-mode shell must call freenetBridge with the user token and hosted flag; got: {html}"
);
assert!(
!html.contains(&format!("freenetBridge(\"{}\");", token.as_str())),
"hosted-mode shell must not emit the 1-arg freenetBridge call"
);
}
#[tokio::test]
async fn shell_page_non_hosted_mode_omits_user_token() {
let token = AuthToken::generate();
let html = response_body(
shell_page(&token, "testkey123", ApiVersion::V1, None, None, false).unwrap(),
)
.await;
assert!(
!html.contains("__freenet_user_token"),
"non-hosted shell must not mint a per-user token; got: {html}"
);
assert!(
!html.contains("localStorage.setItem"),
"non-hosted shell must not persist a per-user token; got: {html}"
);
assert!(
!html.contains(", __freenet_user_token)"),
"non-hosted shell must not call freenetBridge with a user token"
);
assert!(
html.contains(&format!("freenetBridge(\"{}\");", token.as_str())),
"non-hosted shell must emit the original 1-arg freenetBridge call; got: {html}"
);
}
#[test]
fn bridge_js_appends_user_token_param() {
assert!(
SHELL_BRIDGE_JS.contains("function freenetBridge(authToken, userToken, hostedMode)"),
"bridge function must accept the per-user token and hosted-mode arguments"
);
assert!(
SHELL_BRIDGE_JS.contains("u.searchParams.set('userToken', userToken)"),
"bridge must append userToken to the real WebSocket URL"
);
assert!(
SHELL_BRIDGE_JS.contains("if (userToken"),
"bridge must only append userToken when present (non-hosted = undefined)"
);
assert!(
SHELL_USER_TOKEN_JS.contains("crypto.getRandomValues"),
"user-token snippet must mint the token from OS entropy"
);
assert!(
SHELL_USER_TOKEN_JS.contains("__freenet_user_token__"),
"user-token snippet must persist under the durable localStorage key"
);
}
#[test]
fn user_token_never_transmitted_over_plaintext() {
let https_guard = SHELL_USER_TOKEN_JS
.find("location.protocol !== 'https:'")
.expect("user-token snippet must refuse to run on a non-https page");
let first_storage_access = SHELL_USER_TOKEN_JS
.find("localStorage.getItem")
.expect("user-token snippet must read from localStorage");
assert!(
https_guard < first_storage_access,
"the https guard must run BEFORE any localStorage access so an http \
page never even reads a previously-minted token"
);
assert!(
SHELL_USER_TOKEN_JS.contains("return undefined"),
"the non-https branch must yield an undefined token"
);
assert!(
SHELL_BRIDGE_JS.contains("location.protocol === 'https:'"),
"bridge must gate the userToken append on a secure connection"
);
let https_attach_guard = SHELL_BRIDGE_JS
.find("userToken && location.protocol === 'https:'")
.expect("bridge must only attach userToken over https");
let set_user = SHELL_BRIDGE_JS
.find("u.searchParams.set('userToken', userToken)")
.expect("bridge must have a userToken append site");
assert!(
https_attach_guard < set_user,
"the https guard must precede the userToken append"
);
}
#[tokio::test]
async fn hosted_shell_fails_closed_when_no_user_token() {
let token = AuthToken::generate();
let html = response_body(
shell_page(&token, "testkey123", ApiVersion::V1, None, None, true).unwrap(),
)
.await;
assert!(
html.contains(&format!(
"freenetBridge(\"{}\", __freenet_user_token, true);",
token.as_str()
)),
"hosted shell must pass the hosted flag (true) to the bridge; got: {html}"
);
assert!(
SHELL_BRIDGE_JS.contains("hostedMode === true && !userToken"),
"fail-closed must require hosted mode AND an absent token (any cause)"
);
assert!(
!SHELL_BRIDGE_JS.contains("hostedMode === true && location.protocol"),
"fail-closed must not key off the protocol (misses https+no-storage)"
);
assert!(
SHELL_BRIDGE_JS.contains("removeChild(iframe)"),
"fail-closed must not load the app iframe"
);
assert!(
SHELL_BRIDGE_JS.contains("role', 'alert'") || SHELL_BRIDGE_JS.contains("'alert'"),
"fail-closed must render a visible alert message"
);
let refuse = SHELL_BRIDGE_JS
.find("if (hostedNoToken)")
.expect("WS-open handler must refuse while hosted+no-token");
let open_socket = SHELL_BRIDGE_JS
.find("new WebSocket(u.toString()")
.expect("bridge must have a WebSocket open site");
assert!(
refuse < open_socket,
"the hostedNoToken refusal must precede opening the real socket"
);
}
#[tokio::test]
async fn non_hosted_shell_never_fails_closed() {
let token = AuthToken::generate();
let html = response_body(
shell_page(&token, "testkey123", ApiVersion::V1, None, None, false).unwrap(),
)
.await;
assert!(
html.contains(&format!("freenetBridge(\"{}\");", token.as_str())),
"non-hosted shell must use the 1-arg freenetBridge call; got: {html}"
);
assert!(
!html.contains(", true);"),
"non-hosted shell must not pass the hosted-mode flag to the bridge"
);
}
#[test]
fn bridge_js_strips_caller_supplied_user_token_before_injecting() {
let delete_user = SHELL_BRIDGE_JS
.find("u.searchParams.delete('userToken')")
.expect("bridge must delete any caller-supplied userToken");
let delete_auth = SHELL_BRIDGE_JS
.find("u.searchParams.delete('authToken')")
.expect("bridge must delete any caller-supplied authToken (defense-in-depth)");
let set_auth = SHELL_BRIDGE_JS
.find("u.searchParams.set('authToken', authToken)")
.expect("bridge must inject the shell's authToken");
let conditional_set_user = SHELL_BRIDGE_JS
.find("u.searchParams.set('userToken', userToken)")
.expect("bridge must conditionally inject the shell's minted userToken");
assert!(
delete_user < conditional_set_user,
"delete('userToken') must run before the conditional set so a caller \
token cannot survive when the shell's token is undefined"
);
assert!(
delete_user < set_auth && delete_auth < set_auth,
"credential deletes must run before the authToken injection"
);
}
#[test]
fn bridge_js_contains_origin_check() {
assert!(
SHELL_BRIDGE_JS.contains("LOCAL_API_ORIGIN"),
"bridge JS must validate WebSocket origin"
);
assert!(
SHELL_BRIDGE_JS.contains("u.protocol !== 'ws:'"),
"bridge JS must explicitly check WebSocket protocol"
);
assert!(
SHELL_BRIDGE_JS.contains("MAX_CONNECTIONS"),
"bridge JS must limit concurrent connections"
);
assert!(
SHELL_BRIDGE_JS.contains("connections.delete(msg.id)"),
"bridge JS must clean up connections"
);
assert!(
SHELL_BRIDGE_JS.contains("typeof msg.title === 'string'"),
"bridge JS must type-check title before setting"
);
assert!(
SHELL_BRIDGE_JS.contains("typeof msg.href === 'string'"),
"bridge JS must type-check favicon href before setting"
);
assert!(
SHELL_BRIDGE_JS.contains("scheme !== 'https' && scheme !== 'data'"),
"bridge JS must restrict favicon href to https/data schemes"
);
assert!(
SHELL_BRIDGE_JS.contains("msg.type === 'hash'"),
"bridge JS must handle hash shell messages"
);
assert!(
SHELL_BRIDGE_JS.contains("h.charAt(0) === '#'"),
"bridge JS must require # prefix on hash values"
);
assert!(
SHELL_BRIDGE_JS.contains("location.hash.slice(0, 8192)"),
"bridge JS must truncate hash to 8192 chars"
);
assert!(
SHELL_BRIDGE_JS.contains("history.replaceState"),
"bridge JS must use replaceState for hash updates to avoid polluting browser history"
);
assert!(
SHELL_BRIDGE_JS.contains("iframe.getAttribute('data-src')"),
"bridge JS must read base URL from data-src attribute"
);
assert!(
SHELL_BRIDGE_JS.contains("iframe.src = iframeSrc"),
"bridge JS must set iframe src from data-src (single load, no race)"
);
assert!(
!SHELL_BRIDGE_JS.contains("iframe.addEventListener('load'"),
"bridge JS must NOT use load event (race with WASM init; hash is in iframe URL via data-src)"
);
assert!(
!SHELL_BRIDGE_JS.contains("slice(0, 1024)"),
"hash limit must be 8192, not 1024"
);
assert!(
SHELL_BRIDGE_JS.contains("popstate"),
"bridge JS must forward hash on browser back/forward"
);
assert!(
SHELL_BRIDGE_JS.contains("hashchange"),
"bridge JS must forward hash on manual URL fragment edits"
);
assert!(
SHELL_BRIDGE_JS.contains("if (location.hash)"),
"bridge JS must not forward empty hash to iframe"
);
assert!(
SHELL_BRIDGE_JS.contains("msg.type === 'clipboard'"),
"bridge JS must handle clipboard shell messages"
);
assert!(
SHELL_BRIDGE_JS.contains("navigator.clipboard.writeText"),
"bridge JS must proxy clipboard writes through the shell"
);
assert!(
SHELL_BRIDGE_JS.contains("msg.text.slice(0, 2048)"),
"bridge JS must truncate clipboard text to 2048 chars"
);
assert!(
SHELL_BRIDGE_JS.contains("lastClipboard"),
"bridge JS must rate-limit clipboard writes"
);
assert!(
!SHELL_BRIDGE_JS.contains("clipboard.readText")
&& !SHELL_BRIDGE_JS.contains("clipboard.read("),
"bridge JS must be clipboard write-only — no read access"
);
}
#[test]
fn shim_js_validates_message_source() {
assert!(
WEBSOCKET_SHIM_JS.contains("event.source !== window.parent"),
"shim JS must validate message source"
);
}
#[test]
fn get_path_v1() {
let req_path = "/v1/contract/HjpgVdSziPUmxFoBgTdMkQ8xiwhXdv1qn5ouQvSaApzD/state.html";
let base_dir = PathBuf::from(
"/tmp/freenet/webapp_cache/HjpgVdSziPUmxFoBgTdMkQ8xiwhXdv1qn5ouQvSaApzD/",
);
let uri: axum::http::Uri = req_path.parse().unwrap();
let parsed = get_file_path(uri).unwrap();
let result = base_dir.join(parsed);
assert_eq!(
PathBuf::from(
"/tmp/freenet/webapp_cache/HjpgVdSziPUmxFoBgTdMkQ8xiwhXdv1qn5ouQvSaApzD/state.html"
),
result
);
}
#[test]
fn get_path_v2() {
let req_path = "/v2/contract/HjpgVdSziPUmxFoBgTdMkQ8xiwhXdv1qn5ouQvSaApzD/state.html";
let base_dir = PathBuf::from(
"/tmp/freenet/webapp_cache/HjpgVdSziPUmxFoBgTdMkQ8xiwhXdv1qn5ouQvSaApzD/",
);
let uri: axum::http::Uri = req_path.parse().unwrap();
let parsed = get_file_path(uri).unwrap();
let result = base_dir.join(parsed);
assert_eq!(
PathBuf::from(
"/tmp/freenet/webapp_cache/HjpgVdSziPUmxFoBgTdMkQ8xiwhXdv1qn5ouQvSaApzD/state.html"
),
result
);
}
#[test]
fn get_path_v2_web() {
let req_path =
"/v2/contract/web/HjpgVdSziPUmxFoBgTdMkQ8xiwhXdv1qn5ouQvSaApzD/assets/app.js";
let uri: axum::http::Uri = req_path.parse().unwrap();
let parsed = get_file_path(uri).unwrap();
assert_eq!(parsed, "assets/app.js");
}
#[test]
fn get_file_path_rejects_unknown_version() {
let req_path = "/v3/contract/web/somekey/assets/app.js";
let uri: axum::http::Uri = req_path.parse().unwrap();
let result = get_file_path(uri);
assert!(result.is_err(), "expected error for /v3/ prefix");
}
#[test]
fn bridge_js_contains_navigate_handler() {
assert!(
SHELL_BRIDGE_JS.contains("msg.type === 'navigate'"),
"bridge JS must handle navigate shell messages"
);
assert!(
SHELL_BRIDGE_JS.contains("CONTRACT_PREFIX_RE"),
"navigate handler must reference the contract-shape regex"
);
assert!(
SHELL_BRIDGE_JS.contains("cleanPath.match(CONTRACT_PREFIX_RE)"),
"navigate handler must enforce contract-shape check on target path"
);
assert!(
SHELL_BRIDGE_JS.contains("newContractPrefix === contractPrefix"),
"same-contract branch must compare prefixes"
);
assert!(
SHELL_BRIDGE_JS.contains("resolved.searchParams.set('__sandbox', '1')"),
"same-contract branch must add __sandbox=1 to navigated URL"
);
assert!(
SHELL_BRIDGE_JS.contains("window.location.assign"),
"cross-contract branch must use top-level navigation so the gateway \
regenerates a fresh shell + auth token for the new contract"
);
assert!(
SHELL_BRIDGE_JS
.contains("window.location.assign(cleanPath + resolved.search + cappedHash)"),
"cross-contract branch must preserve the query string via resolved.search"
);
assert!(
SHELL_BRIDGE_JS.contains("resolved.origin !== location.origin"),
"navigate handler must reject cross-origin navigation"
);
assert!(
!SHELL_BRIDGE_JS.contains("allow-top-navigation"),
"sandbox attributes must not be widened as part of the cross-contract nav fix"
);
}
#[derive(Debug, PartialEq, Eq)]
enum NavDecision {
SameContract { new_prefix: String },
CrossContract { new_prefix: String },
Reject(&'static str),
}
fn navigate_shell_check(iframe_src: &str, current_prefix: &str, href: &str) -> NavDecision {
use url::Url;
if href.len() > 4096 {
return NavDecision::Reject("href > 4096 bytes");
}
let base = match Url::parse(iframe_src) {
Ok(u) => u,
Err(_) => return NavDecision::Reject("iframe_src unparseable"),
};
let resolved = match base.join(href) {
Ok(u) => u,
Err(_) => return NavDecision::Reject("href unparseable"),
};
if resolved.origin() != base.origin() {
return NavDecision::Reject("cross-origin");
}
let clean_path = resolved.path();
let re = regex::Regex::new(r"^(/v[12]/contract/web/[^/]+/)").unwrap();
let caps = match re.captures(clean_path) {
Some(c) => c,
None => return NavDecision::Reject("shape check failed"),
};
let new_prefix = caps.get(1).unwrap().as_str().to_string();
if new_prefix == current_prefix {
NavDecision::SameContract { new_prefix }
} else {
NavDecision::CrossContract { new_prefix }
}
}
const IFRAME_SRC: &str = "http://127.0.0.1:50509/v1/contract/web/AAAA/?__sandbox=1";
const CURRENT: &str = "/v1/contract/web/AAAA/";
#[test]
fn navigate_same_contract_subpage() {
let d = navigate_shell_check(
IFRAME_SRC,
CURRENT,
"http://127.0.0.1:50509/v1/contract/web/AAAA/page2",
);
assert_eq!(
d,
NavDecision::SameContract {
new_prefix: "/v1/contract/web/AAAA/".to_string()
}
);
}
#[test]
fn navigate_cross_contract_hop() {
let d = navigate_shell_check(
IFRAME_SRC,
CURRENT,
"http://127.0.0.1:50509/v1/contract/web/BBBB/welcome",
);
assert_eq!(
d,
NavDecision::CrossContract {
new_prefix: "/v1/contract/web/BBBB/".to_string()
}
);
}
#[test]
fn navigate_cross_contract_v2_api() {
assert!(matches!(
navigate_shell_check(
IFRAME_SRC,
CURRENT,
"http://127.0.0.1:50509/v2/contract/web/CCCC/app"
),
NavDecision::CrossContract { .. }
));
}
#[test]
fn navigate_relative_same_contract() {
assert!(matches!(
navigate_shell_check(IFRAME_SRC, CURRENT, "page2"),
NavDecision::SameContract { .. }
));
}
#[test]
fn navigate_rejects_gateway_internal_path() {
for evil in [
"http://127.0.0.1:50509/v1/node/status",
"http://127.0.0.1:50509/v1/delegate/foo",
"http://127.0.0.1:50509/api/secret",
"http://127.0.0.1:50509/",
"http://127.0.0.1:50509/v1/contract/AAAA/",
"http://127.0.0.1:50509/v3/contract/web/AAAA/",
] {
assert!(
matches!(
navigate_shell_check(IFRAME_SRC, CURRENT, evil),
NavDecision::Reject(_)
),
"non-contract path must be rejected: {evil}"
);
}
}
#[test]
fn navigate_rejects_path_traversal() {
for evil in [
"http://127.0.0.1:50509/v1/contract/web/AAAA/../../node/status",
"http://127.0.0.1:50509/v1/contract/web/AAAA/../../v1/node/status",
"../../node/status",
] {
let d = navigate_shell_check(IFRAME_SRC, CURRENT, evil);
assert!(
matches!(d, NavDecision::Reject(_)),
"traversal must be rejected post-normalization: {evil} -> {d:?}"
);
}
}
#[test]
fn navigate_rejects_cross_origin() {
for evil in [
"http://evil.example.com/v1/contract/web/AAAA/",
"https://127.0.0.1:50509/v1/contract/web/AAAA/",
"//evil.example.com/v1/contract/web/AAAA/",
] {
assert!(
matches!(
navigate_shell_check(IFRAME_SRC, CURRENT, evil),
NavDecision::Reject("cross-origin")
),
"cross-origin must be rejected: {evil}"
);
}
}
#[test]
fn navigate_rejects_non_http_schemes() {
for evil in [
"javascript:alert(1)",
"data:text/html,<script>",
"file:///etc/passwd",
] {
let d = navigate_shell_check(IFRAME_SRC, CURRENT, evil);
assert!(
matches!(d, NavDecision::Reject(_)),
"non-http scheme must be rejected: {evil} -> {d:?}"
);
}
}
#[test]
fn navigate_rejects_oversized_href() {
let huge = format!(
"http://127.0.0.1:50509/v1/contract/web/AAAA/{}",
"a".repeat(5000)
);
assert!(matches!(
navigate_shell_check(IFRAME_SRC, CURRENT, &huge),
NavDecision::Reject("href > 4096 bytes")
));
}
#[test]
fn navigate_rejects_empty_contract_key_segment() {
assert!(matches!(
navigate_shell_check(
IFRAME_SRC,
CURRENT,
"http://127.0.0.1:50509/v1/contract/web//foo"
),
NavDecision::Reject(_)
));
}
#[test]
fn navigate_rejects_missing_trailing_slash() {
assert!(matches!(
navigate_shell_check(
IFRAME_SRC,
CURRENT,
"http://127.0.0.1:50509/v1/contract/web/AAAA"
),
NavDecision::Reject(_)
));
}
#[test]
fn navigation_interceptor_js_intercepts_clicks() {
assert!(
NAVIGATION_INTERCEPTOR_JS.contains("document.addEventListener('click'"),
"interceptor must listen for click events"
);
assert!(
NAVIGATION_INTERCEPTOR_JS.contains("type: 'navigate'"),
"interceptor must send navigate messages to shell"
);
assert!(
NAVIGATION_INTERCEPTOR_JS.contains("__freenet_shell__: true"),
"interceptor must use __freenet_shell__ namespace"
);
assert!(
NAVIGATION_INTERCEPTOR_JS.contains("e.preventDefault()"),
"interceptor must prevent default link behavior"
);
assert!(
NAVIGATION_INTERCEPTOR_JS.contains("type: 'open_url'"),
"interceptor must route cross-origin links through open_url"
);
assert!(
NAVIGATION_INTERCEPTOR_JS.contains("target.target"),
"interceptor must respect target attribute on same-origin links"
);
assert!(
NAVIGATION_INTERCEPTOR_JS.contains("target.parentElement"),
"interceptor must walk up DOM to find <a> ancestor"
);
}
#[test]
fn navigation_interceptor_handles_cross_origin_target_blank() {
let js = NAVIGATION_INTERCEPTOR_JS;
let cross_origin_idx = js
.find("target.origin !== location.origin")
.expect("cross-origin check present");
let target_attr_idx = js
.find("target.target && target.target !== '_self'")
.expect("target-attribute check present");
assert!(
cross_origin_idx < target_attr_idx,
"cross-origin classification must run before the target-attribute \
skip, otherwise target=\"_blank\" cross-origin links bypass the \
open_url bridge (freenet/river#208). cross_origin_idx={cross_origin_idx}, \
target_attr_idx={target_attr_idx}"
);
let cross_origin_block = &js[cross_origin_idx..target_attr_idx];
assert!(
cross_origin_block.contains("preventDefault"),
"cross-origin branch must preventDefault before opening popup"
);
assert!(
cross_origin_block.contains("type: 'open_url'"),
"cross-origin branch must send open_url, not navigate"
);
}
#[test]
fn navigation_interceptor_forwards_shift_key_for_open_url() {
let js = NAVIGATION_INTERCEPTOR_JS;
let cross_origin_idx = js
.find("type: 'open_url'")
.expect("interceptor open_url branch present");
let target_attr_idx = js
.find("target.target && target.target !== '_self'")
.expect("same-origin target check present");
let block = &js[cross_origin_idx..target_attr_idx];
assert!(
block.contains("shiftKey"),
"cross-origin open_url postMessage must include shiftKey to honour \
shift-click as a new-window request (#3853); got block: {block}"
);
assert!(
block.contains("e.shiftKey"),
"interceptor must forward `e.shiftKey` from the MouseEvent, not a literal (#3853)"
);
}
#[test]
fn navigation_interceptor_listens_on_click_and_auxclick() {
let js = NAVIGATION_INTERCEPTOR_JS;
assert!(
js.contains("addEventListener('click'"),
"interceptor must register a click listener"
);
assert!(
js.contains("addEventListener('auxclick'"),
"interceptor must register an auxclick listener so middle-click \
on cross-origin links is also routed through open_url (#3853)"
);
}
#[test]
fn shell_open_url_handler_honours_shift_key() {
let js = SHELL_BRIDGE_JS;
let open_url_idx = js
.find("msg.type === 'open_url'")
.expect("shell open_url branch present");
let rest = &js[open_url_idx..];
let next_branch = rest[1..]
.find("} else if")
.map(|i| i + 1)
.unwrap_or(rest.len());
let block = &rest[..next_branch];
assert!(
block.contains("msg.shiftKey"),
"open_url handler must read msg.shiftKey for new-window intent (#3853)"
);
assert!(
block.contains("'noopener,noreferrer,popup'"),
"open_url handler must pass the `popup` window feature on shift-click \
so Firefox honours the new-window intent (#3853); got block: {block}"
);
assert!(
block.contains("'noopener,noreferrer'"),
"open_url handler must keep the plain new-tab path for non-shift clicks"
);
}
#[test]
fn shell_open_url_handler_accepts_http_and_https_but_blocks_localhost() {
let js = SHELL_BRIDGE_JS;
let open_url_idx = js
.find("msg.type === 'open_url'")
.expect("shell open_url branch present");
let rest = &js[open_url_idx..];
let next_branch = rest[1..]
.find("} else if")
.map(|i| i + 1)
.unwrap_or(rest.len());
let block = &rest[..next_branch];
assert!(
block.contains("u.protocol !== 'https:'") && block.contains("u.protocol !== 'http:'"),
"open_url handler must accept both http: and https: schemes \
(freenet/river#231); got block: {block}"
);
assert!(
!block.contains("if (u.protocol !== 'https:') return;"),
"open_url handler must NOT reject http: URLs outright; the bug \
this test pins (freenet/river#231) was that an https-only filter \
silently dropped clicks on http: links the user pasted. Got: {block}"
);
assert!(
block.contains("'localhost'") && block.contains("'127.0.0.1'"),
"open_url handler must continue to block localhost/loopback hosts \
so http: scheme acceptance doesn't open a CSRF surface against \
services on the reader's machine; got block: {block}"
);
}
#[test]
fn shell_open_url_handler_blocks_ipv6_loopback_without_brackets() {
let js = SHELL_BRIDGE_JS;
let open_url_idx = js
.find("msg.type === 'open_url'")
.expect("shell open_url branch present");
let rest = &js[open_url_idx..];
let next_branch = rest[1..]
.find("} else if")
.map(|i| i + 1)
.unwrap_or(rest.len());
let block = &rest[..next_branch];
assert!(
block.contains("'::1'"),
"open_url handler must block the IPv6 loopback hostname `::1` \
(no brackets — URL.hostname strips them); got block: {block}"
);
assert!(
!block.contains("'[::1]'"),
"open_url handler must NOT compare against `[::1]` with \
brackets — that arm is dead because URL.hostname strips \
brackets from IPv6 literals; got block: {block}"
);
}
#[test]
fn shell_open_url_handler_rejects_dangerous_schemes() {
let js = SHELL_BRIDGE_JS;
let open_url_idx = js
.find("msg.type === 'open_url'")
.expect("shell open_url branch present");
let rest = &js[open_url_idx..];
let next_branch = rest[1..]
.find("} else if")
.map(|i| i + 1)
.unwrap_or(rest.len());
let block = &rest[..next_branch];
assert!(
block.contains("u.protocol !== 'https:'")
&& block.contains("u.protocol !== 'http:'")
&& block.contains("&&"),
"open_url handler must use an explicit `http:` AND `https:` \
allow-list (joined with &&) so dangerous schemes \
(javascript:, data:, file:, blob:, chrome:, vbscript:) \
are rejected by the shell-side check, which is the \
primary scheme gate (a malicious iframe can postMessage \
open_url without going through the upstream interceptor); \
got block: {block}"
);
}
#[tokio::test]
async fn sandbox_content_serves_sub_pages() {
let dir = tempfile::tempdir().unwrap();
let key = "testkey123";
let sub_html = r#"<!DOCTYPE html><html><head></head><body><h1>News</h1></body></html>"#;
std::fs::write(dir.path().join("news.html"), sub_html).unwrap();
let result = response_body(
sandbox_content_body(dir.path(), key, ApiVersion::V1, "news.html")
.await
.unwrap(),
)
.await;
assert!(
result.contains("<h1>News</h1>"),
"sub-page content not served"
);
assert!(
result.contains("FreenetWebSocket"),
"WebSocket shim not injected in sub-page"
);
assert!(
result.contains("type: 'navigate'"),
"navigation interceptor not injected in sub-page"
);
}
#[tokio::test]
async fn sandbox_content_serves_directory_index() {
let dir = tempfile::tempdir().unwrap();
let key = "testkey123";
std::fs::create_dir(dir.path().join("news")).unwrap();
let sub_html =
r#"<!DOCTYPE html><html><head></head><body><h1>News Index</h1></body></html>"#;
std::fs::write(dir.path().join("news/index.html"), sub_html).unwrap();
let result = response_body(
sandbox_content_body(dir.path(), key, ApiVersion::V1, "news")
.await
.unwrap(),
)
.await;
assert!(
result.contains("<h1>News Index</h1>"),
"directory index.html not served"
);
assert!(
result.contains("FreenetWebSocket"),
"WebSocket shim not injected in directory index"
);
}
#[tokio::test]
async fn sandbox_content_rejects_path_traversal() {
let dir = tempfile::tempdir().unwrap();
let key = "testkey123";
std::fs::write(dir.path().join("index.html"), "<html></html>").unwrap();
let result =
sandbox_content_body(dir.path(), key, ApiVersion::V1, "../../../etc/passwd").await;
assert!(result.is_err(), "path traversal should be rejected");
}
#[tokio::test]
async fn sandbox_content_rejects_absolute_path() {
let dir = tempfile::tempdir().unwrap();
let key = "testkey123";
std::fs::write(dir.path().join("index.html"), "<html></html>").unwrap();
let result = sandbox_content_body(dir.path(), key, ApiVersion::V1, "/etc/passwd").await;
assert!(result.is_err(), "absolute path should be rejected");
}
#[cfg(unix)]
#[tokio::test]
async fn sandbox_content_rejects_symlink_escape() {
let dir = tempfile::tempdir().unwrap();
let key = "testkey123";
let outside = tempfile::tempdir().unwrap();
std::fs::write(outside.path().join("secret.html"), "<html>secret</html>").unwrap();
std::os::unix::fs::symlink(
outside.path().join("secret.html"),
dir.path().join("escape.html"),
)
.unwrap();
let result = sandbox_content_body(dir.path(), key, ApiVersion::V1, "escape.html").await;
assert!(result.is_err(), "symlink escape should be rejected");
}
#[test]
fn bridge_js_navigate_pushes_history_state() {
assert!(
SHELL_BRIDGE_JS.contains("history.pushState"),
"navigate handler must push a history entry"
);
assert!(
SHELL_BRIDGE_JS.contains("__freenet_nav__: true"),
"history state must be tagged with __freenet_nav__ so popstate can recognise it"
);
assert!(
SHELL_BRIDGE_JS.contains("iframePath: newIframePath"),
"history state must carry the iframe sandbox URL for popstate restore"
);
assert!(
SHELL_BRIDGE_JS.contains("cleanPath + cappedHash"),
"pushState URL must be the clean (non-sandbox) path"
);
}
#[test]
fn bridge_js_popstate_restores_iframe_from_state() {
assert!(
SHELL_BRIDGE_JS.contains("addEventListener('popstate'"),
"bridge JS must listen for popstate events"
);
assert!(
SHELL_BRIDGE_JS.contains("state.__freenet_nav__ === true"),
"popstate handler must check for the __freenet_nav__ marker"
);
assert!(
SHELL_BRIDGE_JS.contains("state.iframePath.indexOf(contractPrefix) === 0"),
"popstate handler must validate the restored iframe path stays under the contract prefix"
);
assert!(
SHELL_BRIDGE_JS.contains("iframe.src = state.iframePath"),
"popstate handler must restore iframe.src from state"
);
}
#[test]
fn bridge_js_seeds_initial_history_state() {
assert!(
SHELL_BRIDGE_JS.contains("history.replaceState"),
"bridge JS must seed history state on load"
);
assert!(
SHELL_BRIDGE_JS.contains("history.replaceState(history.state"),
"hash replaceState must preserve the existing state object"
);
}
#[test]
fn bridge_js_navigate_caps_href_length() {
assert!(
SHELL_BRIDGE_JS.contains("msg.href.length > 4096"),
"navigate handler must cap msg.href length"
);
assert!(
SHELL_BRIDGE_JS.contains("resolved.hash.slice(0, 8192)"),
"navigate handler must cap the hash component stored in history.state"
);
}
#[test]
fn bridge_js_hash_update_syncs_nav_state() {
assert!(
SHELL_BRIDGE_JS.contains("curState.__freenet_nav__ === true"),
"hash handler must detect tagged nav state"
);
assert!(
SHELL_BRIDGE_JS.contains("basePath + h"),
"hash handler must rewrite iframePath with the new fragment"
);
}
#[test]
fn bridge_js_popstate_skips_reload_when_iframe_on_target() {
assert!(
SHELL_BRIDGE_JS.contains("iframe.src.indexOf(state.iframePath) === -1"),
"popstate handler must skip reload when iframe is already on the target"
);
}
#[test]
fn bridge_js_cleans_up_websockets_on_navigate() {
assert!(
SHELL_BRIDGE_JS.contains("connections.forEach"),
"navigate handler must close existing WebSocket connections"
);
assert!(
SHELL_BRIDGE_JS.contains("connections.clear()"),
"navigate handler must clear the connections map"
);
}
}