solid-pod-rs-server 0.5.0-alpha.21

Drop-in Solid Pod server binary. Wraps solid-pod-rs with actix-web and a JSS-compatible layered config loader.
Documentation
//! Git WAC-gating integration tests (ADR-059 D6, Phase 1).
//!
//! Before this fix `handle_git` handed every git smart-HTTP request straight
//! to the CGI with no authorisation check: a private pod's history was
//! anonymously clonable and pushes were anonymous (R5 "No WAC" finding). The
//! server now resolves the caller's `did:nostr` from the git
//! `Basic nostr:`/`Nostr` NIP-98 credential and enforces Read for
//! clone/fetch and Write for push against the pod-root container ACL.
//!
//! The gate assertions are deterministic and CGI-independent: the WAC gate runs
//! *before* the `git http-backend` CGI is ever spawned, so a denial surfaces
//! as 401/403 regardless of whether a git repo (or the CGI binary) exists. A
//! public pod that passes the gate but has no initialised repo surfaces as
//! 404 (`NotARepository`) — which is exactly how we tell "gate denied"
//! (401/403) apart from "gate allowed, nothing to serve" (404). The one
//! test that proves the owner's export is served needs the real CGI, found
//! through `git --exec-path`.
//!
//! Run with: `cargo test -p solid-pod-rs-server --features git`.
#![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";

/// Point the server at the installed `git-http-backend`, as the deployment
/// does (`GIT_HTTP_BACKEND_PATH="$(git --exec-path)/git-http-backend"`)
/// and as the git and forge suites do. The library default is the Debian
/// path, so without this the export test fails with 500 on any other
/// layout (Nix, macOS) before it reaches the authority check it guards.
/// It does not skip: a missing backend is a failure. Set at most once per
/// test binary; every test in it that reaches the CGI wants the same value.
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);
}

/// Owner-only ACL: a single rule granting one `did:nostr` Read+Write over the
/// `/alice/` container and its descendants. No `foaf:Agent` class, so an
/// anonymous caller is denied.
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"}}]
            }}]
        }}"##
    )
}

/// Public ACL: `foaf:Agent` may Read the container — anonymous reads pass.
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"}}
            }}]
        }}"##
    )
}

/// Anyone may read and write: a pod where WAC alone would admit a push.
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"}]"#,
    )
}

/// The push refusal is the server's own, not a WAC verdict (ER-28).
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}"
    );
}

/// Build an fs-backed `AppState` rooted at a fresh temp dir, with `data_root`
/// set (git requires it), seeding pod containers + their `.acl` sidecars.
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 {
        // The pod container dir must exist so `handle_git`'s
        // `repo_root.exists()` check passes.
        std::fs::create_dir_all(root.join(pod)).expect("create pod dir");
        // The effective ACL for container `/{pod}/` is the *sibling*
        // sidecar `/{pod}.acl` (resolver.rs:70 — `{trimmed}.acl`), which is
        // the same path every other authz check in the server resolves. Write
        // it there, next to the container dir, not inside it.
        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)
}

/// JSS #548: every git response — including WAC denials and service errors —
/// must carry the git CORS headers, or a browser-based git client sees a
/// generic CORS/network error instead of the actual status and challenge.
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;

    // Anonymous capability advertisement (the first request of a clone).
    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;
}

/// ER-28: push is not served at all. A pod whose ACL lets anyone write still
/// refuses `git-receive-pack` and its `info/refs` discovery, and the refusal
/// leaves nothing on disk (no auto-initialised repository).
#[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() {
    // Public pod, but no git repo initialised: the WAC gate must ALLOW the
    // anonymous read (public foaf:Agent Read), after which the git service
    // reports 404 NotARepository. A 404 (not 401/403) proves the gate opened.
    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"
    );
    // The gate-passed error path (404 NotARepository from the git service)
    // must carry the git CORS headers too (#548).
    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)
    );
}