diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..61afe6a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,77 @@ +name: ci + +# PR + push-to-main gate for the middleware modules. Until this existed the +# only automation was release.yml (tags / dispatch), so modules could land with +# no build/lint/test. This closes that hole: fmt, clippy, test, and a release +# build all run on every pull request and every push to main. +# +# Runners are GitHub-HOSTED on purpose (same reasoning as release.yml): these +# modules are pure Rust with no PHP SDK, so they don't need — and must not +# contend with — the self-hosted ephemerd fleet that builds ePHPm itself and +# the php-sdk tarballs. + +on: + pull_request: + push: + branches: [main] + +# Cancel superseded runs on the same ref (a new push to a PR aborts the old +# in-flight run) so we don't burn hosted minutes on stale commits. +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + # The ephpm-middleware ABI crate is a git dependency; fetch via the git CLI so + # host git rewrite rules apply — same as release.yml. + CARGO_NET_GIT_FETCH_WITH_CLI: "true" + +permissions: + contents: read + +jobs: + # ── Formatting: rustfmt uses unstable options, so nightly is required ──────── + fmt: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install nightly + rustfmt + run: | + rustup toolchain install nightly --profile minimal --component rustfmt + rustup default nightly + - run: cargo +nightly fmt --all -- --check + + # ── Lint: pedantic clippy, warnings are errors (must not be weakened) ──────── + clippy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install stable + clippy + run: | + rustup toolchain install stable --profile minimal --component clippy + rustup default stable + - run: cargo clippy --workspace --all-targets -- -D warnings + + # ── Tests: ~80 unit tests + the fail-open / deny integration binaries ──────── + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install stable + run: | + rustup toolchain install stable --profile minimal + rustup default stable + - run: cargo test --workspace + + # ── Build: release-compile every cdylib to prove each module links. This is + # what catches a broken module before a release tag would. ─────────────── + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install stable + run: | + rustup toolchain install stable --profile minimal + rustup default stable + - run: cargo build --workspace --release diff --git a/Cargo.lock b/Cargo.lock index 944c54c..82ea37c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -234,6 +234,7 @@ version = "0.1.0" dependencies = [ "ephpm-middleware", "ephpm-middleware-modules", + "serde_json", ] [[package]] diff --git a/crates/ephpm-middleware-ip-allowlist/Cargo.toml b/crates/ephpm-middleware-ip-allowlist/Cargo.toml index 3f4ccb4..7eef4c8 100644 --- a/crates/ephpm-middleware-ip-allowlist/Cargo.toml +++ b/crates/ephpm-middleware-ip-allowlist/Cargo.toml @@ -17,5 +17,13 @@ crate-type = ["cdylib", "rlib"] ephpm-middleware.workspace = true ephpm-middleware-modules.workspace = true +[dev-dependencies] +# The deny-path integration test drives the loaded `IpAllowlist` through the +# shell crate's re-export and the `host` feature's `RequestCtx`/`host_table`, +# asserting a real 403 RESPOND verdict. No KV store is wired in — this gate is +# pure CIDR policy, so the test is deterministic and never flaky. +ephpm-middleware = { workspace = true, features = ["host"] } +serde_json.workspace = true + [lints] workspace = true diff --git a/crates/ephpm-middleware-ip-allowlist/tests/deny.rs b/crates/ephpm-middleware-ip-allowlist/tests/deny.rs new file mode 100644 index 0000000..4ece7a0 --- /dev/null +++ b/crates/ephpm-middleware-ip-allowlist/tests/deny.rs @@ -0,0 +1,57 @@ +//! Fail-CLOSED deny path, driven through the loadable shell crate. +//! +//! The sibling `ratelimit` / `maintenance-mode` integration tests only assert +//! the fail-OPEN `CONTINUE` verdict. This one exercises the opposite — the +//! access-control gate producing a real `403` `RESPOND` — end to end through +//! the same surface the host uses: the module type as re-exported by the +//! *shell* crate (`ephpm_middleware_ip_allowlist::IpAllowlist`, the crate that +//! becomes the shipped cdylib), driven via the `host` feature's fabricated +//! `RequestCtx` and the real `host_table()`. +//! +//! It needs no ephpm binary and no KV store — the verdict is pure CIDR policy, +//! so the test is deterministic (never flaky). It proves that a built module, +//! reached through the ABI-facing `Request`/`Response` types, denies an +//! out-of-policy client with the exact status and body the module documents. +#![allow(unsafe_code)] // builds the FFI Request view by hand, like the unit tests. + +use ephpm_middleware::Middleware; +use ephpm_middleware::abi::{ACTION_CONTINUE, ACTION_RESPOND}; +use ephpm_middleware::host::{RequestCtx, host_table}; +use ephpm_middleware_ip_allowlist::IpAllowlist; + +/// Drive the module for one client IP and return the ABI `Response`. +fn invoke(mw: &IpAllowlist, ip: &str) -> ephpm_middleware::Response { + let ctx = RequestCtx::new("GET", "/index.php", "", ip, "example.test", &[]); + // SAFETY: `ctx` outlives the view; host_table() is 'static. + let req = unsafe { ephpm_middleware::Request::from_raw(ctx.as_abi(), host_table()) }; + mw.invoke(&req) +} + +#[test] +fn out_of_policy_ip_is_denied_403() { + // Allow only the RFC1918 10/8 block; everything else hits the default deny. + let mw = IpAllowlist::init(&serde_json::json!({ "allow": ["10.0.0.0/8"] })).expect("init"); + + // An in-range client passes straight through to PHP. + assert_eq!(invoke(&mw, "10.1.2.3").__action(), ACTION_CONTINUE); + + // An out-of-range client is rejected with a real 403 RESPOND verdict — + // action, status, and the plain-text body all asserted. + let resp = invoke(&mw, "203.0.113.9"); + assert_eq!(resp.__action(), ACTION_RESPOND); + assert_eq!(resp.__status(), 403); + assert!(!resp.__body().is_empty()); +} + +#[test] +fn explicit_deny_beats_allow() { + // Same address in both lists with default=allow: deny must still win (403). + let mw = IpAllowlist::init(&serde_json::json!({ + "allow": ["10.0.0.0/8"], + "deny": ["10.6.6.6/32"], + "default": "allow", + })) + .expect("init"); + assert_eq!(invoke(&mw, "10.6.6.6").__action(), ACTION_RESPOND); + assert_eq!(invoke(&mw, "10.6.6.7").__action(), ACTION_CONTINUE); +}