#![cfg(feature = "git")]
use std::sync::Arc;
use solid_pod_rs::storage::fs::FsBackend;
use solid_pod_rs_server::{build_app, AppState};
const OWNER: &str = "did:nostr:6e1cf6...owner";
fn use_installed_git_http_backend() {
static SET: std::sync::Once = std::sync::Once::new();
SET.call_once(set_git_http_backend_from_exec_path);
}
fn set_git_http_backend_from_exec_path() {
let out = std::process::Command::new("git")
.arg("--exec-path")
.output()
.expect("git runs");
let backend = std::path::PathBuf::from(String::from_utf8_lossy(&out.stdout).trim())
.join("git-http-backend");
assert!(
backend.is_file(),
"git-http-backend not found under `git --exec-path`: {}",
backend.display()
);
std::env::set_var("GIT_HTTP_BACKEND_PATH", backend);
}
fn owner_only_acl(container: &str) -> String {
format!(
r##"{{
"@context": {{"acl": "http://www.w3.org/ns/auth/acl#"}},
"@graph": [{{
"@id": "#owner",
"acl:agent": {{"@id": "{OWNER}"}},
"acl:accessTo": {{"@id": "{container}"}},
"acl:default": {{"@id": "{container}"}},
"acl:mode": [{{"@id": "acl:Read"}}, {{"@id": "acl:Write"}}]
}}]
}}"##
)
}
fn public_read_acl(container: &str) -> String {
format!(
r##"{{
"@context": {{
"acl": "http://www.w3.org/ns/auth/acl#",
"foaf": "http://xmlns.com/foaf/0.1/"
}},
"@graph": [{{
"@id": "#public",
"acl:agentClass": {{"@id": "foaf:Agent"}},
"acl:accessTo": {{"@id": "{container}"}},
"acl:default": {{"@id": "{container}"}},
"acl:mode": {{"@id": "acl:Read"}}
}}]
}}"##
)
}
fn public_write_acl(container: &str) -> String {
public_read_acl(container).replace(
r#""acl:mode": {"@id": "acl:Read"}"#,
r#""acl:mode": [{"@id": "acl:Read"}, {"@id": "acl:Write"}, {"@id": "acl:Append"}]"#,
)
}
async fn assert_push_not_served<B: actix_web::body::MessageBody>(
rsp: actix_web::dev::ServiceResponse<B>,
ctx: &str,
) {
assert_eq!(rsp.status().as_u16(), 403, "{ctx}: push must be refused");
assert_git_cors(&rsp, ctx);
let body = actix_web::test::read_body(rsp).await;
let text = String::from_utf8_lossy(&body);
assert!(
text.contains("push_not_served"),
"{ctx}: wrong refusal: {text}"
);
}
async fn fs_state_with_pods(pods: &[(&str, String)]) -> (AppState, tempfile::TempDir) {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().to_path_buf();
for (pod, acl) in pods {
std::fs::create_dir_all(root.join(pod)).expect("create pod dir");
std::fs::write(root.join(format!("{pod}.acl")), acl).expect("write .acl");
}
let fs = FsBackend::new(root.clone()).await.expect("fs backend");
let mut state = AppState::new(Arc::new(fs));
state.data_root = Some(root);
(state, tmp)
}
fn assert_git_cors<B>(rsp: &actix_web::dev::ServiceResponse<B>, ctx: &str) {
for (name, want) in solid_pod_rs_git::service::GIT_CORS_HEADERS {
let got = rsp
.headers()
.get(name)
.unwrap_or_else(|| panic!("{ctx}: missing git CORS header {name}"))
.to_str()
.expect("header value utf-8");
assert_eq!(got, want, "{ctx}: wrong value for {name}");
}
}
#[actix_web::test]
async fn anonymous_clone_of_private_pod_is_denied() {
let (state, _tmp) = fs_state_with_pods(&[("alice", owner_only_acl("/alice/"))]).await;
let app = actix_web::test::init_service(build_app(state)).await;
let req = actix_web::test::TestRequest::get()
.uri("/alice/info/refs?service=git-upload-pack")
.to_request();
let rsp = actix_web::test::call_service(&app, req).await;
let status = rsp.status().as_u16();
assert!(
status == 401 || status == 403,
"anonymous clone of a private pod must be denied by WAC before the CGI \
(expected 401/403, got {status})"
);
assert_git_cors(&rsp, "WAC-denied clone (#548)");
}
#[actix_web::test]
async fn anonymous_push_to_private_pod_is_denied() {
let (state, _tmp) = fs_state_with_pods(&[("alice", owner_only_acl("/alice/"))]).await;
let app = actix_web::test::init_service(build_app(state)).await;
let req = actix_web::test::TestRequest::post()
.uri("/alice/git-receive-pack")
.insert_header(("content-type", "application/x-git-receive-pack-request"))
.set_payload(actix_web::web::Bytes::from_static(b""))
.to_request();
let rsp = actix_web::test::call_service(&app, req).await;
assert_push_not_served(rsp, "anonymous push to a private pod").await;
}
#[actix_web::test]
async fn push_is_refused_even_where_wac_would_allow_it() {
let (state, tmp) = fs_state_with_pods(&[("open", public_write_acl("/open/"))]).await;
let app = actix_web::test::init_service(build_app(state)).await;
let req = actix_web::test::TestRequest::post()
.uri("/open/git-receive-pack")
.insert_header(("content-type", "application/x-git-receive-pack-request"))
.set_payload(actix_web::web::Bytes::from_static(b""))
.to_request();
let rsp = actix_web::test::call_service(&app, req).await;
assert_push_not_served(rsp, "receive-pack on a world-writable pod").await;
let req = actix_web::test::TestRequest::get()
.uri("/open/info/refs?service=git-receive-pack")
.to_request();
let rsp = actix_web::test::call_service(&app, req).await;
assert_push_not_served(rsp, "push discovery on a world-writable pod").await;
assert!(
!tmp.path().join("open/.git").exists(),
"a refused push auto-initialised a repository"
);
assert!(
!tmp.path().join("open/git-receive-pack").exists(),
"the push fell through to LDP and created a resource"
);
}
#[actix_web::test]
async fn public_root_read_does_not_authorise_whole_history_export() {
let (state, _tmp) = fs_state_with_pods(&[("bob", public_read_acl("/bob/"))]).await;
let app = actix_web::test::init_service(build_app(state)).await;
let req = actix_web::test::TestRequest::get()
.uri("/bob/info/refs?service=git-upload-pack")
.to_request();
let rsp = actix_web::test::call_service(&app, req).await;
let status = rsp.status().as_u16();
assert_eq!(
status, 403,
"public root Read cannot authorise private historical resources"
);
assert_git_cors(&rsp, "no-repo 404 after gate pass (#548)");
}
#[actix_web::test]
async fn owner_can_export_a_single_pod_path() {
let secret = "0000000000000000000000000000000000000000000000000000000000000019";
let key = k256::schnorr::SigningKey::from_slice(&hex::decode(secret).unwrap()).unwrap();
let pod = hex::encode(key.verifying_key().to_bytes());
let path = format!("/{pod}/info/refs?service=git-upload-pack");
let (state, tmp) = fs_state_with_pods(&[(&pod, public_read_acl(&format!("/{pod}/")))]).await;
let repo = tmp.path().join(&pod);
let output = std::process::Command::new("git")
.args(["init", "--quiet", "--bare", "."])
.current_dir(&repo)
.output()
.unwrap();
assert!(output.status.success());
use_installed_git_http_backend();
let app = actix_web::test::init_service(build_app(state)).await;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let token = solid_pod_rs::auth::nip98::mint_with_payload(
&format!("http://localhost:8080{path}"),
"GET",
None,
secret,
now,
)
.unwrap();
let request = actix_web::test::TestRequest::get()
.uri(&path)
.insert_header(("authorization", format!("Nostr {token}")))
.to_request();
let response = actix_web::test::call_service(&app, request).await;
assert_git_cors(&response, "owner export");
let status = response.status().as_u16();
let body = actix_web::test::read_body(response).await;
assert_eq!(
status,
200,
"owner export failed: {}",
String::from_utf8_lossy(&body)
);
}