#![cfg(feature = "path-encrypt")]
use std::sync::Arc;
use axum::Router;
use axum::body::Body;
use axum::http::{HeaderMap, HeaderValue, Request, StatusCode, header};
use http_body_util::BodyExt;
use libfw_core::auth::{AuthError, PathValidator, TokenVerifier};
use libfw_core::claims::{Permission, TokenClaims};
use libfw_core::metadata::{FileMeta, encode_file_meta_header};
use libfw_core::pathmap::{EncryptedPathCodec, PathCodec};
use libfw_server::{FsStorage, ServerState, router, HEADER_FILE_META};
use serde::Deserialize;
use tower::ServiceExt;
const KEY: [u8; 32] = [7u8; 32];
fn state() -> Arc<ServerState> {
Arc::new(
ServerState::builder()
.storage(FsStorage::new(tempfile::tempdir().unwrap().path()))
.verifier(DevVerifier)
.validator(PathValidator::new())
.path_codec(EncryptedPathCodec::new(KEY))
.build(),
)
}
#[derive(Clone)]
struct DevVerifier;
impl TokenVerifier for DevVerifier {
fn verify(&self, token: &str) -> Result<TokenClaims, AuthError> {
Ok(TokenClaims {
sub: token.to_string(),
exp: None,
permissions: vec![Permission::Read, Permission::Write],
allowed_paths: vec!["/".to_string()],
})
}
}
#[derive(Clone)]
struct RestrictedVerifier;
impl TokenVerifier for RestrictedVerifier {
fn verify(&self, token: &str) -> Result<TokenClaims, AuthError> {
Ok(TokenClaims {
sub: token.to_string(),
exp: None,
permissions: vec![Permission::Read, Permission::Write],
allowed_paths: vec!["/public/".to_string()],
})
}
}
fn restricted_state() -> Arc<ServerState> {
Arc::new(
ServerState::builder()
.storage(FsStorage::new(tempfile::tempdir().unwrap().path()))
.verifier(RestrictedVerifier)
.validator(PathValidator::new())
.path_codec(EncryptedPathCodec::new(KEY))
.build(),
)
}
fn auth_header(token: &str) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(
header::AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {token}")).unwrap(),
);
headers
}
async fn body_string(response: axum::response::Response) -> String {
let bytes = response.into_body().collect().await.unwrap().to_bytes();
String::from_utf8(bytes.to_vec()).unwrap()
}
fn request(method: &str, uri: &str, headers: HeaderMap, body: Body) -> Request<Body> {
let mut builder = Request::builder().method(method).uri(uri);
for (name, value) in headers.iter() {
builder = builder.header(name, value);
}
builder.body(body).unwrap()
}
async fn upload_through_shadow(app: &Router, shadow: &str, data: &[u8]) {
let meta = FileMeta::new("ignored/echo", data.len() as u64, 1_700_000_000);
let mut headers = auth_header("tok");
headers.insert(
HEADER_FILE_META,
HeaderValue::from_str(&encode_file_meta_header(&meta)).unwrap(),
);
let resp = app
.clone()
.oneshot(request("POST", &format!("/file/{shadow}"), headers, Body::from(data.to_vec())))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::CREATED,
"body: {}",
body_string(resp).await
);
}
#[tokio::test]
async fn shadow_lists_and_transfers_roundtrip() {
let state = state();
let app = router(state.clone());
let codec = EncryptedPathCodec::new(KEY);
let data = b"shadow path round-trip payload".repeat(64);
let shadow = codec.encode("docs/a.txt");
assert_ne!(shadow, "docs/a.txt", "encrypted shadow must be opaque");
upload_through_shadow(&app, &shadow, &data).await;
let meta = state.storage.file_meta("docs/a.txt").await.unwrap().unwrap();
assert_eq!(meta.size, data.len() as u64);
let resp = app
.clone()
.oneshot(request("GET", &format!("/file/{shadow}"), auth_header("tok"), Body::empty()))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(body_string(resp).await.into_bytes(), data);
let docs_shadow = codec.encode("docs");
let resp = app
.clone()
.oneshot(request("GET", &format!("/dir/{docs_shadow}"), auth_header("tok"), Body::empty()))
.await
.unwrap();
if resp.status() != StatusCode::OK {
panic!("list failed: {}", resp.status());
}
let listing: DirList = serde_json::from_str(&body_string(resp).await).unwrap();
let listed = listing
.0
.iter()
.find(|e| codec.decode(&e.path).ok().as_deref() == Some("docs/a.txt"))
.expect("real docs/a.txt should appear in the listing");
let resp = app
.clone()
.oneshot(request("GET", &format!("/file/{}", listed.path), auth_header("tok"), Body::empty()))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(body_string(resp).await.into_bytes(), data);
}
#[tokio::test]
async fn allowed_paths_apply_to_real_path_not_shadow() {
let state = restricted_state();
let app = router(state.clone());
let codec = EncryptedPathCodec::new(KEY);
let in_shadow = codec.encode("public/a.txt");
upload_through_shadow(&app, &in_shadow, b"hello public").await;
let resp = app
.clone()
.oneshot(request("GET", &format!("/file/{in_shadow}"), auth_header("tok"), Body::empty()))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK, "allowed subtree must pass");
let out_shadow = codec.encode("etc/passwd");
let resp = app
.clone()
.oneshot(request("GET", &format!("/file/{out_shadow}"), auth_header("tok"), Body::empty()))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"real path outside allowed_paths must be rejected through a shadow"
);
}
#[tokio::test]
async fn tampered_shadow_is_400() {
let state = state();
let app = router(state.clone());
let codec = EncryptedPathCodec::new(KEY);
let mut shadow = codec.encode("docs/a.txt").into_bytes();
let last = *shadow.last().unwrap();
*shadow.last_mut().unwrap() = if last == b'A' { b'B' } else { b'A' };
let tampered = String::from_utf8(shadow).unwrap();
assert_ne!(tampered, codec.encode("docs/a.txt"), "tamper must change it");
let resp = app
.clone()
.oneshot(request("GET", &format!("/file/{tampered}"), auth_header("tok"), Body::empty()))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::BAD_REQUEST,
"GCM authentication failure must surface as 400"
);
}
#[derive(Deserialize)]
struct DirList(Vec<DirEntry>);
#[derive(Deserialize)]
struct DirEntry {
path: String,
}