diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..2be703e --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,96 @@ +#!/bin/sh +# Refuse commits whose staged Go, Rust or TypeScript files are not formatted. +# +# The checks read the working-tree copy of each staged file, not the staged +# blob, so with partial staging (git add -p) the result can differ from what +# is committed. The pre-push hook (make lint-all) and CI are the backstop. +set -eu + +cd "$(git rev-parse --show-toplevel)" + +staged=$(git -c core.quotePath=false diff --cached --name-only --diff-filter=ACMR) +[ -n "$staged" ] || exit 0 + +# With quotePath=false git still quotes names containing a newline, a double +# quote or a backslash. Those cannot be matched reliably, so fail closed. +quoted=$(printf '%s\n' "$staged" | grep '^"' || true) +if [ -n "$quoted" ]; then + echo "pre-commit: cannot check file names that git quotes:" >&2 + printf ' %s\n' "$quoted" >&2 + echo " rename the file, or bypass once with git commit --no-verify" >&2 + echo " (pre-push and CI still check formatting)" >&2 + exit 1 +fi + +go_files=$(printf '%s\n' "$staged" | grep -E '^go/.*\.go$' || true) +rs_files=$(printf '%s\n' "$staged" | grep -E '^rs/.*\.rs$' || true) +ts_files=$(printf '%s\n' "$staged" | grep -E '^ts/src/.*\.ts$' | sed 's|^ts/||' || true) + +failed="" + +fail() { + failed="$failed $1" +} + +# Split file lists on newlines only, and never glob-expand them. +set -f +nl=' +' + +if [ -n "$go_files" ]; then + if ! command -v gofmt >/dev/null 2>&1; then + echo "pre-commit: gofmt not found on PATH (install Go)" >&2 + fail go + else + old_ifs=$IFS + IFS=$nl + # shellcheck disable=SC2086 # intentional newline splitting + if ! out=$(gofmt -l -- $go_files 2>&1) || [ -n "$out" ]; then + echo "pre-commit: gofmt needed:" >&2 + printf '%s\n' "$out" >&2 + fail go + fi + IFS=$old_ifs + fi +fi + +if [ -n "$rs_files" ]; then + if ! command -v cargo >/dev/null 2>&1; then + echo "pre-commit: cargo not found on PATH (install Rust via rustup)" >&2 + fail rs + # Checks the whole crate, not only the staged files: rustfmt follows mod declarations. + elif ! out=$(cd rs && cargo fmt --check 2>&1); then + printf '%s\n' "$out" >&2 + echo "pre-commit: cargo fmt needed" >&2 + fail rs + fi +fi + +if [ -n "$ts_files" ]; then + if [ ! -x ts/node_modules/.bin/prettier ]; then + echo "pre-commit: prettier not found at ts/node_modules/.bin/prettier (run npm ci in ts/)" >&2 + fail ts + else + old_ifs=$IFS + IFS=$nl + # shellcheck disable=SC2086 # intentional newline splitting + if ! out=$(cd ts && node_modules/.bin/prettier --check -- $ts_files 2>&1); then + printf '%s\n' "$out" >&2 + echo "pre-commit: prettier needed" >&2 + fail ts + fi + IFS=$old_ifs + fi +fi + +if [ -n "$failed" ]; then + echo >&2 + echo "pre-commit: formatting check failed for:$failed" >&2 + for lang in $failed; do + echo " run: make fmt-$lang" >&2 + done + echo " bypass once: git commit --no-verify" >&2 + exit 1 +fi + +exit 0 diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..fdeb62c --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,13 @@ +#!/bin/sh +# Refuse pushes that do not pass make lint-all (formatting, vet, typecheck). +# +# This lints the working tree, not the commits being pushed: uncommitted +# changes can mask or cause failures. CI checks the pushed commits. +set -eu + +cd "$(git rev-parse --show-toplevel)" + +if ! make lint-all; then + echo "lint failed; fix it or bypass once with git push --no-verify" >&2 + exit 1 +fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92a8d69..5633090 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,7 @@ jobs: - uses: actions-rust-lang/setup-rust-toolchain@v1 with: toolchain: "1.85" + components: rustfmt - run: make lint-rs - run: make test-rs - run: make build-rs diff --git a/AGENTS.md b/AGENTS.md index 5fae961..a7e4063 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,8 @@ deploy/ — Docker Compose + integration tests - `make build-go` / `make test-go` / `make lint-go` — Go only - `make build-rs` / `make test-rs` / `make lint-rs` — Rust only - `make build-ts` / `make test-ts` / `make lint-ts` — TypeScript only +- `make fmt-all` / `make fmt-go` / `make fmt-rs` / `make fmt-ts` — apply gofmt / rustfmt / prettier (each `lint-*` target checks formatting) +- `make hooks` — enable `.githooks` (pre-commit format check, pre-push `make lint-all`) - `make verify` — Quint spec simulation (request handling + listener) - `make test-spec` — Quint `run` tests for `spec/listener.qnt` and `spec/router.qnt` - `make test-integration` — Docker Compose integration tests diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 55980e5..8fc499a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,6 +17,19 @@ Thank you for your interest! This project uses a multi-language approach — Go, - Test all: `make test-all` - Lint all: `make lint-all` +### Git hooks (recommended) + +Run `make hooks` once per clone. It sets `core.hooksPath` to `.githooks`, which enables: + +- `pre-commit` — checks formatting when Go, Rust or TypeScript files are staged: the staged Go (`gofmt`) and TypeScript (`prettier`) files, and the whole Rust crate (`cargo fmt --check`), because rustfmt follows `mod` declarations +- `pre-push` — runs `make lint-all` + +Both hooks check the working tree, not the commits being pushed or the exact staged content, so uncommitted changes can affect the result. CI checks what you push. + +To fix formatting, run `make fmt-go`, `make fmt-rs`, `make fmt-ts`, or `make fmt-all`. Prettier comes from `ts/node_modules`, so run `npm ci` in `ts/` first. + +To bypass a hook once, pass `--no-verify` to `git commit` or `git push`. CI runs the same checks. + ## Architecture All three languages implement the same architecture: diff --git a/Makefile b/Makefile index 6dde96b..fb8b780 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ ROUTER_SPEC := spec/router.qnt BACKEND ?= .PHONY: build clean test lint verify typecheck test-spec validate ci-verify release-verify test-release -.PHONY: build-go test-go lint-go build-rs test-rs build-ts test-ts +.PHONY: build-go test-go lint-go build-rs test-rs lint-rs build-ts test-ts lint-ts build-all test-all lint-all fmt-go fmt-rs fmt-ts fmt-all hooks # ─── Go ────────────────────────────────────────────── @@ -19,8 +19,12 @@ test-go: cd go && go test ./... -count=1 lint-go: + cd go && out=$$(gofmt -l . 2>&1); test -z "$$out" || { echo "gofmt needed (run make fmt-go):"; echo "$$out"; exit 1; } cd go && go vet ./... +fmt-go: + cd go && gofmt -w . + # ─── Rust ──────────────────────────────────────────── build-rs: @@ -30,7 +34,10 @@ test-rs: cd rs && cargo test lint-rs: - cd rs && cargo check + cd rs && cargo fmt --check && cargo check + +fmt-rs: + cd rs && cargo fmt # ─── Rust release binary location RS_BINARY = rs/target/release/docker-socket-policy @@ -44,13 +51,22 @@ test-ts: cd ts && npm run build && node --test dist/*.test.js lint-ts: - cd ts && npm run typecheck + cd ts && npm run typecheck && npm run format:check + +fmt-ts: + @test -x ts/node_modules/.bin/prettier || { echo "prettier not installed: run npm ci in ts/"; exit 1; } + cd ts && node_modules/.bin/prettier --write src # ─── Aggregate targets ─────────────────────────────── build-all: build-go build-rs build-ts test-all: test-go test-rs test-ts lint-all: lint-go lint-rs lint-ts +fmt-all: fmt-go fmt-rs fmt-ts + +hooks: + git config core.hooksPath .githooks + @echo "git hooks active: core.hooksPath=.githooks (pre-commit, pre-push)" # ─── Legacy aliases (default to Go) ────────────────── diff --git a/README.md b/README.md index 3ee179e..a761c94 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ make test-all # Lint all three make lint-all -# Full validation: typecheck + verify + vet + test (Go) +# Full validation: typecheck + verify + gofmt + vet + test (Go) make validate ``` diff --git a/go/internal/audit/audit_test.go b/go/internal/audit/audit_test.go index e95b501..6d19007 100644 --- a/go/internal/audit/audit_test.go +++ b/go/internal/audit/audit_test.go @@ -117,4 +117,4 @@ func assertField(t *testing.T, e map[string]interface{}, key, expected string) { if got, ok := e[key].(string); !ok || got != expected { t.Fatalf("expected %s=%q, got %v", key, expected, e[key]) } -} \ No newline at end of file +} diff --git a/go/internal/proxy/router.go b/go/internal/proxy/router.go index 8ad03b9..41ee75a 100644 --- a/go/internal/proxy/router.go +++ b/go/internal/proxy/router.go @@ -11,7 +11,7 @@ import ( type Action int const ( - ActionDeny Action = iota + ActionDeny Action = iota ActionAllow ActionCreateContainer ) diff --git a/go/internal/proxy/transport_test.go b/go/internal/proxy/transport_test.go index ad7851a..11392cf 100644 --- a/go/internal/proxy/transport_test.go +++ b/go/internal/proxy/transport_test.go @@ -80,4 +80,4 @@ type osSyscallError struct { } func (e *osSyscallError) Error() string { return "connect: " + e.err.Error() } -func (e *osSyscallError) Unwrap() error { return e.err } \ No newline at end of file +func (e *osSyscallError) Unwrap() error { return e.err } diff --git a/rs/rustfmt.toml b/rs/rustfmt.toml new file mode 100644 index 0000000..7530651 --- /dev/null +++ b/rs/rustfmt.toml @@ -0,0 +1 @@ +max_width = 120 diff --git a/rs/src/audit.rs b/rs/src/audit.rs index 103253b..12c727d 100644 --- a/rs/src/audit.rs +++ b/rs/src/audit.rs @@ -8,13 +8,8 @@ pub struct AuditLogger { impl AuditLogger { pub fn new(path: &str) -> Result> { - let file = OpenOptions::new() - .create(true) - .append(true) - .open(path)?; - Ok(AuditLogger { - file: Mutex::new(file), - }) + let file = OpenOptions::new().create(true).append(true).open(path)?; + Ok(AuditLogger { file: Mutex::new(file) }) } pub fn nop() -> Self { @@ -23,9 +18,7 @@ impl AuditLogger { .append(true) .open("/dev/null") .expect("failed to open /dev/null"); - AuditLogger { - file: Mutex::new(file), - } + AuditLogger { file: Mutex::new(file) } } pub fn allow(&self, method: &str, uri: &str) { diff --git a/rs/src/handler.rs b/rs/src/handler.rs index 094ca37..85c46e5 100644 --- a/rs/src/handler.rs +++ b/rs/src/handler.rs @@ -5,8 +5,8 @@ use crate::transport::Transport; use crate::transport::TransportError; use bytes::Bytes; use http_body_util::BodyExt; -use hyper::{Request, Response, StatusCode, header}; use http_body_util::Full; +use hyper::{header, Request, Response, StatusCode}; use std::collections::HashMap; use std::sync::Arc; use tracing::{info, warn}; @@ -20,7 +20,12 @@ pub struct Handler { impl Handler { pub fn new(router: Arc, chain: Chain, audit: AuditLogger, transport: Box) -> Self { - Handler { router, chain, audit, transport } + Handler { + router, + chain, + audit, + transport, + } } pub async fn handle(&self, req: Request) -> Response> @@ -130,11 +135,18 @@ mod tests { } impl MockTransport { - fn new() -> (Self, Arc>>, Arc>>) { + fn new() -> ( + Self, + Arc>>, + Arc>>, + ) { let captured = Arc::new(std::sync::Mutex::new(None)); let captured_headers = Arc::new(std::sync::Mutex::new(None)); ( - MockTransport { captured_body: captured.clone(), captured_headers: captured_headers.clone() }, + MockTransport { + captured_body: captured.clone(), + captured_headers: captured_headers.clone(), + }, captured, captured_headers, ) @@ -143,10 +155,7 @@ mod tests { #[async_trait] impl Transport for MockTransport { - async fn forward( - &self, - req: Request>, - ) -> Result>, TransportError> { + async fn forward(&self, req: Request>) -> Result>, TransportError> { *self.captured_headers.lock().unwrap() = Some(req.headers().clone()); let body = req.into_body(); let collected = http_body_util::BodyExt::collect(body) @@ -161,7 +170,11 @@ mod tests { } } - fn make_test_handler() -> (Handler, Arc>>, Arc>>) { + fn make_test_handler() -> ( + Handler, + Arc>>, + Arc>>, + ) { use crate::policy::ContainerConfig; let mut policies = std::collections::HashMap::new(); policies.insert( @@ -213,8 +226,7 @@ mod tests { assert_eq!(resp.status(), StatusCode::OK); let captured = captured.lock().unwrap(); - let captured_body: serde_json::Value = - serde_json::from_slice(captured.as_ref().unwrap()).unwrap(); + let captured_body: serde_json::Value = serde_json::from_slice(captured.as_ref().unwrap()).unwrap(); assert!(captured_body.get("HostConfig").is_some()); let hc = captured_body.get("HostConfig").unwrap(); assert_eq!(hc.get("Privileged"), Some(&serde_json::Value::Bool(false))); @@ -244,17 +256,14 @@ mod tests { #[async_trait] impl Transport for FailingTransport { - async fn forward( - &self, - _req: Request>, - ) -> Result>, TransportError> { + async fn forward(&self, _req: Request>) -> Result>, TransportError> { match self.kind { - FailureKind::SocketPermission => Err(TransportError::SocketPermission( - std::io::Error::from(std::io::ErrorKind::PermissionDenied), - )), - FailureKind::Other => Err(TransportError::Other( - Box::::from("connection refused"), - )), + FailureKind::SocketPermission => Err(TransportError::SocketPermission(std::io::Error::from( + std::io::ErrorKind::PermissionDenied, + ))), + FailureKind::Other => Err(TransportError::Other(Box::::from( + "connection refused", + ))), } } } @@ -373,10 +382,7 @@ mod tests { #[async_trait] impl Transport for UriRecordingTransport { - async fn forward( - &self, - req: Request>, - ) -> Result>, TransportError> { + async fn forward(&self, req: Request>) -> Result>, TransportError> { *self.captured_uri.lock().unwrap() = Some(req.uri().clone()); Ok(Response::builder() .status(StatusCode::OK) @@ -391,7 +397,9 @@ mod tests { let chain = Chain::new(false); let audit = AuditLogger::new("/dev/null").unwrap(); let captured_uri = Arc::new(std::sync::Mutex::new(None)); - let transport = UriRecordingTransport { captured_uri: captured_uri.clone() }; + let transport = UriRecordingTransport { + captured_uri: captured_uri.clone(), + }; (Handler::new(router, chain, audit, Box::new(transport)), captured_uri) } diff --git a/rs/src/main.rs b/rs/src/main.rs index 4db85da..4758f77 100644 --- a/rs/src/main.rs +++ b/rs/src/main.rs @@ -202,7 +202,10 @@ fn acquire_instance_lock(socket_path: &str) -> io::Result { if e.raw_os_error() == Some(libc::EWOULDBLOCK) { return Err(io::Error::new( io::ErrorKind::AddrInUse, - format!("{} is in use by another instance (lock {} held)", socket_path, lock_path), + format!( + "{} is in use by another instance (lock {} held)", + socket_path, lock_path + ), )); } return Err(io::Error::new(e.kind(), format!("locking {}: {}", lock_path, e))); @@ -239,7 +242,12 @@ fn prepare_socket_path(path: &str) -> io::Result<()> { std::thread::spawn(move || { let _ = tx.send(std::os::unix::net::UnixStream::connect(target).map(drop)); }); - let in_use = || io::Error::new(io::ErrorKind::AddrInUse, format!("{} is in use by another process", path)); + let in_use = || { + io::Error::new( + io::ErrorKind::AddrInUse, + format!("{} is in use by another process", path), + ) + }; match rx.recv_timeout(PROBE_TIMEOUT) { Ok(Ok(())) | Err(std::sync::mpsc::RecvTimeoutError::Timeout) => return Err(in_use()), Ok(Err(e)) if e.raw_os_error() == Some(libc::ECONNREFUSED) => {} @@ -257,8 +265,7 @@ fn prepare_socket_path(path: &str) -> io::Result<()> { } } - std::fs::remove_file(path) - .map_err(|e| io::Error::new(e.kind(), format!("removing stale socket {}: {}", path, e))) + std::fs::remove_file(path).map_err(|e| io::Error::new(e.kind(), format!("removing stale socket {}: {}", path, e))) } /// Binds the Unix socket listener for `--listen-socket`. @@ -344,8 +351,8 @@ fn resolve_group(group: &str) -> Result { )), }; } - let name = std::ffi::CString::new(group) - .map_err(|_| format!("--listen-socket-group {:?}: contains a NUL byte", group))?; + let name = + std::ffi::CString::new(group).map_err(|_| format!("--listen-socket-group {:?}: contains a NUL byte", group))?; // getgrnam_r is the reentrant form; the non-_r variant returns a pointer // into a shared static buffer. @@ -415,10 +422,7 @@ fn validate_docker_host(addr: &str) -> Result<(), String> { .iter() .any(|s| addr.starts_with(s)) { - return Err(format!( - "--docker-host only supports Unix socket paths, got: {}", - addr - )); + return Err(format!("--docker-host only supports Unix socket paths, got: {}", addr)); } Ok(()) } @@ -473,10 +477,8 @@ fn spawn_unix_listener( // request finishes rather than waiting out the timeout. drop(listener); unlink_listen_socket(&addr); - let drained = tokio::time::timeout(SHUTDOWN_TIMEOUT, async { - while conns.join_next().await.is_some() {} - }) - .await; + let drained = + tokio::time::timeout(SHUTDOWN_TIMEOUT, async { while conns.join_next().await.is_some() {} }).await; if drained.is_err() { tracing::error!( "drain deadline exceeded after {:?}, abandoning in-flight connections", @@ -501,11 +503,8 @@ fn unlink_listen_socket(addr: &str) { } } -async fn serve_connection( - handler: Arc, - stream: S, - mut shutdown_rx: broadcast::Receiver<()>, -) where +async fn serve_connection(handler: Arc, stream: S, mut shutdown_rx: broadcast::Receiver<()>) +where S: AsyncRead + AsyncWrite + Unpin + Send + 'static, { let io = TokioIo::new(stream); @@ -615,7 +614,11 @@ mod tests { let path = dir.join("important.txt"); std::fs::write(&path, b"important data").unwrap(); let err = open_listener(&path, None).expect_err("regular file was not refused"); - assert!(err.to_string().contains("not a socket"), "error = {:?}", err.to_string()); + assert!( + err.to_string().contains("not a socket"), + "error = {:?}", + err.to_string() + ); assert_eq!(std::fs::read(&path).unwrap(), b"important data", "file was modified"); let path = dir.join("adir"); @@ -678,7 +681,12 @@ mod tests { std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap(); let err = prepare_socket_path(&path).expect_err("0000 socket was not refused"); let want = format!("refusing to remove {}", path); - assert!(err.to_string().starts_with(&want), "error = {:?}, want prefix {:?}", err.to_string(), want); + assert!( + err.to_string().starts_with(&want), + "error = {:?}, want prefix {:?}", + err.to_string(), + want + ); assert!(std::fs::symlink_metadata(&path).is_ok(), "socket was removed"); } @@ -689,7 +697,12 @@ mod tests { std::fs::write(&path, b"data").unwrap(); let err = prepare_socket_path(&path).expect_err("regular file was not refused"); let want = format!("refusing to remove {}: not a socket", path); - assert!(err.to_string().starts_with(&want), "error = {:?}, want prefix {:?}", err.to_string(), want); + assert!( + err.to_string().starts_with(&want), + "error = {:?}, want prefix {:?}", + err.to_string(), + want + ); assert_eq!(std::fs::read(&path).unwrap(), b"data", "regular file was modified"); } } @@ -738,7 +751,10 @@ mod tests { err.to_string(), path ); - assert!(std::fs::symlink_metadata(&target).is_err(), "symlink target was created"); + assert!( + std::fs::symlink_metadata(&target).is_err(), + "symlink target was created" + ); } #[test] @@ -789,7 +805,11 @@ mod tests { for result in results { match result { Ok(pair) => { - assert!(winner.is_none(), "iteration {}: more than one open_listener succeeded", i); + assert!( + winner.is_none(), + "iteration {}: more than one open_listener succeeded", + i + ); winner = Some(pair); } Err(e) => assert_eq!(e.to_string(), want, "iteration {}: loser error", i), @@ -836,7 +856,12 @@ mod tests { let mut fds = [0 as libc::c_int; 2]; // SAFETY: fds has room for the two descriptors pipe(2) writes. - assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0, "pipe: {}", io::Error::last_os_error()); + assert_eq!( + unsafe { libc::pipe(fds.as_mut_ptr()) }, + 0, + "pipe: {}", + io::Error::last_os_error() + ); let (read_fd, write_fd) = (fds[0], fds[1]); // SAFETY: sysconf(3) has no preconditions. let max_fd = unsafe { libc::sysconf(libc::_SC_OPEN_MAX) }.clamp(256, 65536) as libc::c_int; @@ -876,22 +901,38 @@ mod tests { // SAFETY: write_fd is ours and open. unsafe { libc::close(write_fd) }; - let mut pfd = libc::pollfd { fd: read_fd, events: libc::POLLIN, revents: 0 }; + let mut pfd = libc::pollfd { + fd: read_fd, + events: libc::POLLIN, + revents: 0, + }; // SAFETY: pfd is a valid pollfd for the duration of the call. let ready = unsafe { libc::poll(&mut pfd, 1, 10_000) }; let mut byte = 0u8; // SAFETY: byte is a valid 1-byte buffer. - let n = if ready == 1 { unsafe { libc::read(read_fd, &mut byte as *mut u8 as *mut libc::c_void, 1) } } else { 0 }; + let n = if ready == 1 { + unsafe { libc::read(read_fd, &mut byte as *mut u8 as *mut libc::c_void, 1) } + } else { + 0 + }; unsafe { libc::close(read_fd) }; drop(fork_lock); assert_eq!(ready, 1, "child did not report ready within 10s"); assert_eq!((n, byte), (1, b'r'), "child failed to take the lock"); - assert!(acquire_instance_lock(&path).is_err(), "acquired the lock while the child held it"); + assert!( + acquire_instance_lock(&path).is_err(), + "acquired the lock while the child held it" + ); // SAFETY: pid is our unreaped child. unsafe { - assert_eq!(libc::kill(pid, libc::SIGKILL), 0, "kill: {}", io::Error::last_os_error()); + assert_eq!( + libc::kill(pid, libc::SIGKILL), + 0, + "kill: {}", + io::Error::last_os_error() + ); assert_eq!(libc::waitpid(pid, std::ptr::null_mut(), 0), pid, "waitpid"); } child.0 = 0; @@ -925,9 +966,14 @@ mod tests { ("dsp.sock", "must be an absolute path"), ]; for (addr, want) in cases { - let err = validate_listen_socket(addr) - .expect_err(&format!("{:?} should be rejected", addr)); - assert!(err.contains(want), "error for {:?} was {:?}, want it to contain {:?}", addr, err, want); + let err = validate_listen_socket(addr).expect_err(&format!("{:?} should be rejected", addr)); + assert!( + err.contains(want), + "error for {:?} was {:?}, want it to contain {:?}", + addr, + err, + want + ); } } @@ -953,8 +999,7 @@ mod tests { ("unix:///var/run/docker.sock", "only supports Unix socket paths"), ]; for (addr, want) in cases { - let err = validate_docker_host(addr) - .expect_err(&format!("{:?} should be rejected", addr)); + let err = validate_docker_host(addr).expect_err(&format!("{:?} should be rejected", addr)); assert!(err.contains(want), "error for {:?} was {:?}", addr, err); } } @@ -964,7 +1009,11 @@ mod tests { let path = unique_socket_path(); let result = bind_unix_listener(path.to_str().unwrap(), None); - assert!(result.is_ok(), "expected bind to a fresh path to succeed: {:?}", result.err()); + assert!( + result.is_ok(), + "expected bind to a fresh path to succeed: {:?}", + result.err() + ); assert!(path.exists(), "expected socket file to be created"); std::fs::remove_file(&path).ok(); @@ -981,8 +1030,7 @@ mod tests { async fn forward( &self, _req: hyper::Request>, - ) -> Result>, transport::TransportError> - { + ) -> Result>, transport::TransportError> { tokio::time::sleep(self.delay).await; Ok(hyper::Response::builder() .status(418) @@ -1139,7 +1187,10 @@ mod tests { for v in ["4294967295", "4294967296", "12345678901234567890"] { assert_eq!( resolve_group(v), - Err(format!("--listen-socket-group {:?}: gid out of range (0-4294967294)", v)) + Err(format!( + "--listen-socket-group {:?}: gid out of range (0-4294967294)", + v + )) ); } // Only a digit string is numeric; a sign makes it a (nonexistent) name. @@ -1159,9 +1210,8 @@ mod tests { _ => Err(format!("--listen-socket-group {:?}: unknown group", name)), } }; - let none = |name: &str| -> Result { - Err(format!("--listen-socket-group {:?}: unknown group", name)) - }; + let none = + |name: &str| -> Result { Err(format!("--listen-socket-group {:?}: unknown group", name)) }; // group_default_present assert_eq!(select_socket_group(None, known, EGID), Ok((2001, None))); @@ -1227,7 +1277,12 @@ mod tests { let result = bind_unix_listener(&path, Some(0)); let err = result.expect_err("bind_unix_listener(path, Some(0)) as non-root succeeded, want EPERM"); let want = "the proxy's user must be a member of it"; - assert!(err.to_string().contains(want), "error = {:?}, want it to contain {:?}", err.to_string(), want); + assert!( + err.to_string().contains(want), + "error = {:?}, want it to contain {:?}", + err.to_string(), + want + ); } /// A clean shutdown must not leave the socket file on disk, matching Go @@ -1253,10 +1308,7 @@ mod tests { .expect("listener did not shut down") .expect("listener task panicked"); - assert!( - !path.exists(), - "socket file was left behind after a clean shutdown" - ); + assert!(!path.exists(), "socket file was left behind after a clean shutdown"); std::fs::remove_file(&path).ok(); } diff --git a/rs/src/middleware.rs b/rs/src/middleware.rs index 48ad61c..4d3dcbb 100644 --- a/rs/src/middleware.rs +++ b/rs/src/middleware.rs @@ -5,7 +5,13 @@ use std::collections::HashMap; use crate::policy::{ContainerConfig, FlagRule, Volume}; pub trait Gate: Send + Sync { - fn check(&self, _method: &str, _path: &str, _policy: &Policy, _body: &HashMap) -> std::result::Result<(), String> { + fn check( + &self, + _method: &str, + _path: &str, + _policy: &Policy, + _body: &HashMap, + ) -> std::result::Result<(), String> { Ok(()) } } @@ -75,12 +81,20 @@ impl Chain { } fn charset_check(s: &str) -> bool { - s.len() <= 256 && s.chars().all(|c| matches!(c, 'A'..='Z' | 'a'..='z' | '0'..='9' | '.' | '_' | '=' | ':' | '/' | ',' | '-')) + s.len() <= 256 + && s.chars() + .all(|c| matches!(c, 'A'..='Z' | 'a'..='z' | '0'..='9' | '.' | '_' | '=' | ':' | '/' | ',' | '-')) } pub struct ExecGate; impl Gate for ExecGate { - fn check(&self, _method: &str, path: &str, _policy: &Policy, _body: &HashMap) -> std::result::Result<(), String> { + fn check( + &self, + _method: &str, + path: &str, + _policy: &Policy, + _body: &HashMap, + ) -> std::result::Result<(), String> { if path.contains("/exec") { return Err("exec is not allowed: docker exec provides a shell escape vector".into()); } @@ -90,11 +104,15 @@ impl Gate for ExecGate { pub struct ReadonlyGate; impl Gate for ReadonlyGate { - fn check(&self, method: &str, _path: &str, _policy: &Policy, _body: &HashMap) -> std::result::Result<(), String> { + fn check( + &self, + method: &str, + _path: &str, + _policy: &Policy, + _body: &HashMap, + ) -> std::result::Result<(), String> { match method { - "POST" | "PUT" | "DELETE" | "PATCH" => { - Err(format!("read-only mode: {} requests are not allowed", method)) - } + "POST" | "PUT" | "DELETE" | "PATCH" => Err(format!("read-only mode: {} requests are not allowed", method)), _ => Ok(()), } } @@ -102,7 +120,13 @@ impl Gate for ReadonlyGate { pub struct RegistryGate; impl Gate for RegistryGate { - fn check(&self, _method: &str, _path: &str, policy: &Policy, body: &HashMap) -> std::result::Result<(), String> { + fn check( + &self, + _method: &str, + _path: &str, + policy: &Policy, + body: &HashMap, + ) -> std::result::Result<(), String> { let image = body.get("Image").and_then(|v| v.as_str()); let image = match image { Some(s) if !s.is_empty() => s, @@ -115,9 +139,10 @@ impl Gate for RegistryGate { (image, "") }; - let allowed = policy.allowed_image_prefixes.iter().any(|prefix| { - image_name == prefix || image_name.starts_with(&format!("{}/", prefix)) - }); + let allowed = policy + .allowed_image_prefixes + .iter() + .any(|prefix| image_name == prefix || image_name.starts_with(&format!("{}/", prefix))); if !allowed { return Err(format!("image {:?} is not in allowed prefixes", image_name)); } @@ -131,16 +156,18 @@ impl Gate for RegistryGate { if !policy.image_digest_allowed { return Err(format!("image digests not allowed for service {}", policy.service_name)); } - let re = regex::Regex::new(r"^sha256:[a-f0-9]{64}$") - .map_err(|e| format!("invalid digest regex: {}", e))?; + let re = + regex::Regex::new(r"^sha256:[a-f0-9]{64}$").map_err(|e| format!("invalid digest regex: {}", e))?; if !re.is_match(tag_or_digest) { return Err(format!("invalid digest format: {}", tag_or_digest)); } } else if let Some(ref pattern) = policy.image_tag_pattern { - let re = regex::Regex::new(pattern) - .map_err(|e| format!("invalid tag pattern regex: {}", e))?; + let re = regex::Regex::new(pattern).map_err(|e| format!("invalid tag pattern regex: {}", e))?; if !re.is_match(tag_or_digest) { - return Err(format!("image tag {:?} does not match pattern {:?}", tag_or_digest, pattern)); + return Err(format!( + "image tag {:?} does not match pattern {:?}", + tag_or_digest, pattern + )); } } } @@ -151,7 +178,13 @@ impl Gate for RegistryGate { pub struct MountSourceGate; impl Gate for MountSourceGate { - fn check(&self, _method: &str, _path: &str, policy: &Policy, body: &HashMap) -> std::result::Result<(), String> { + fn check( + &self, + _method: &str, + _path: &str, + policy: &Policy, + body: &HashMap, + ) -> std::result::Result<(), String> { let volumes = match &policy.volumes { Some(v) if !v.is_empty() => v, _ => return Ok(()), @@ -183,7 +216,13 @@ impl Gate for MountSourceGate { pub struct EnvFileGate; impl Gate for EnvFileGate { - fn check(&self, _method: &str, _path: &str, policy: &Policy, body: &HashMap) -> std::result::Result<(), String> { + fn check( + &self, + _method: &str, + _path: &str, + policy: &Policy, + body: &HashMap, + ) -> std::result::Result<(), String> { let env_file = match &policy.env_file { Some(e) if !e.is_empty() => e, _ => return Ok(()), @@ -215,7 +254,13 @@ impl Gate for EnvFileGate { pub struct CmdGate; impl Gate for CmdGate { - fn check(&self, _method: &str, _path: &str, policy: &Policy, body: &HashMap) -> std::result::Result<(), String> { + fn check( + &self, + _method: &str, + _path: &str, + policy: &Policy, + body: &HashMap, + ) -> std::result::Result<(), String> { let cmd = match body.get("Cmd").and_then(|v| v.as_array()) { Some(c) => c, None => return Ok(()), @@ -240,7 +285,10 @@ impl Gate for CmdGate { } if let Some(allowed) = &policy.allowed_cli_flags { if !allowed.contains(&flag.to_string()) { - return Err(format!("flag {:?} is not in the allowlist for service {}", flag, policy.service_name)); + return Err(format!( + "flag {:?} is not in the allowlist for service {}", + flag, policy.service_name + )); } } @@ -265,7 +313,10 @@ impl Gate for CmdGate { let re = regex::Regex::new(&rule.value_pattern) .map_err(|e| format!("invalid flag rule regex: {}", e))?; if !re.is_match(&value) { - return Err(format!("flag {} value {:?} does not match pattern {:?}", flag, value, rule.value_pattern)); + return Err(format!( + "flag {} value {:?} does not match pattern {:?}", + flag, value, rule.value_pattern + )); } } } @@ -300,9 +351,10 @@ impl Mutator for ContainerConfigMutator { hc.insert("RestartPolicy".to_string(), rp_obj); } if let Some(so) = &cc.security_options { - hc.insert("SecurityOpt".to_string(), serde_json::Value::Array( - so.iter().map(|s| serde_json::Value::String(s.clone())).collect() - )); + hc.insert( + "SecurityOpt".to_string(), + serde_json::Value::Array(so.iter().map(|s| serde_json::Value::String(s.clone())).collect()), + ); } hc.insert("Privileged".to_string(), serde_json::Value::Bool(false)); if let Some(u) = &cc.user { @@ -754,7 +806,9 @@ mod tests { let gate = RegistryGate; let mut policy = make_policy("test", vec!["alpine"]); policy.image_digest_allowed = true; - let body = body_from_json(serde_json::json!({"Image": "alpine@sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"})); + let body = body_from_json( + serde_json::json!({"Image": "alpine@sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"}), + ); assert!(gate.check("POST", "/containers/create", &policy, &body).is_ok()); } diff --git a/rs/src/policy.rs b/rs/src/policy.rs index 392bf3e..7217148 100644 --- a/rs/src/policy.rs +++ b/rs/src/policy.rs @@ -58,7 +58,10 @@ impl Manager { let entries = match std::fs::read_dir(config_dir) { Ok(d) => d, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - eprintln!("warn: config dir {} not found, starting with empty policy set", config_dir); + eprintln!( + "warn: config dir {} not found, starting with empty policy set", + config_dir + ); return Ok(Manager { policies_by_name }); } Err(e) => return Err(e.into()), @@ -197,7 +200,10 @@ denied_flags: #[test] fn test_extract_image_name_registry_path() { - assert_eq!(extract_image_name("registry.example.com/myimage:tag"), "registry.example.com/myimage"); + assert_eq!( + extract_image_name("registry.example.com/myimage:tag"), + "registry.example.com/myimage" + ); } #[test] @@ -316,22 +322,42 @@ denied_flags: #[test] fn test_manager_list() { let mut policies = HashMap::new(); - policies.insert("a".into(), Policy { - service_name: "a".into(), - user_id: None, group_id: None, - allowed_image_prefixes: vec!["img".into()], - image_tag_pattern: None, image_digest_allowed: false, - container_config: None, volumes: None, ports: None, - env_file: None, allowed_cli_flags: None, flag_rules: None, denied_flags: None, - }); - policies.insert("b".into(), Policy { - service_name: "b".into(), - user_id: None, group_id: None, - allowed_image_prefixes: vec!["img".into()], - image_tag_pattern: None, image_digest_allowed: false, - container_config: None, volumes: None, ports: None, - env_file: None, allowed_cli_flags: None, flag_rules: None, denied_flags: None, - }); + policies.insert( + "a".into(), + Policy { + service_name: "a".into(), + user_id: None, + group_id: None, + allowed_image_prefixes: vec!["img".into()], + image_tag_pattern: None, + image_digest_allowed: false, + container_config: None, + volumes: None, + ports: None, + env_file: None, + allowed_cli_flags: None, + flag_rules: None, + denied_flags: None, + }, + ); + policies.insert( + "b".into(), + Policy { + service_name: "b".into(), + user_id: None, + group_id: None, + allowed_image_prefixes: vec!["img".into()], + image_tag_pattern: None, + image_digest_allowed: false, + container_config: None, + volumes: None, + ports: None, + env_file: None, + allowed_cli_flags: None, + flag_rules: None, + denied_flags: None, + }, + ); let mgr = Manager::from_map(policies); let mut keys = mgr.list(); keys.sort(); diff --git a/rs/src/proxy.rs b/rs/src/proxy.rs index 888fcbd..0c1563e 100644 --- a/rs/src/proxy.rs +++ b/rs/src/proxy.rs @@ -28,12 +28,7 @@ impl Router { Router { manager } } - pub fn route( - &self, - method: &str, - path: &str, - body: Option<&HashMap>, - ) -> RouteResult { + pub fn route(&self, method: &str, path: &str, body: Option<&HashMap>) -> RouteResult { // The daemon decodes the path before routing, so deny any escape (#53). if path.contains('%') { return deny("percent-encoded path not allowed"); @@ -208,12 +203,7 @@ fn strip_api_version(path: &str) -> &str { return path; }; let bytes = rest.as_bytes(); - let digits = |from: usize| { - bytes[from..] - .iter() - .take_while(|b| b.is_ascii_digit()) - .count() - }; + let digits = |from: usize| bytes[from..].iter().take_while(|b| b.is_ascii_digit()).count(); let major = digits(0); if major == 0 { return path; @@ -246,7 +236,13 @@ fn extract_container_name(path: &str) -> Option<&str> { let path = path.strip_prefix('/').unwrap_or(path); let parts: Vec<&str> = path.split('/').collect(); // An empty segment is not a name, mirroring Go/TS (#48). - if parts.len() >= 2 && parts[0] == "containers" && !parts[1].is_empty() && parts[1] != "create" && parts[1] != "json" && parts[1] != "exec" { + if parts.len() >= 2 + && parts[0] == "containers" + && !parts[1].is_empty() + && parts[1] != "create" + && parts[1] != "json" + && parts[1] != "exec" + { Some(parts[1]) } else { None @@ -348,9 +344,9 @@ mod tests { #[test] fn test_route_container_create() { let router = Router::new(make_manager(vec!["alpine"])); - let binding = serde_json::from_value::>( - serde_json::json!({"Image": "alpine:latest"}), - ).unwrap(); + let binding = + serde_json::from_value::>(serde_json::json!({"Image": "alpine:latest"})) + .unwrap(); let body = Some(&binding); let result = router.route("POST", "/containers/create", body); assert_eq!(result.action, Action::CreateContainer); @@ -369,9 +365,8 @@ mod tests { #[test] fn test_route_container_create_no_image() { let router = Router::new(make_manager(vec!["alpine"])); - let map: HashMap = serde_json::from_value( - serde_json::json!({"Cmd": ["echo"]}), - ).unwrap(); + let map: HashMap = + serde_json::from_value(serde_json::json!({"Cmd": ["echo"]})).unwrap(); let result = router.route("POST", "/containers/create", Some(&map)); assert_eq!(result.action, Action::Deny); assert!(result.deny_msg.unwrap().contains("image")); @@ -380,9 +375,8 @@ mod tests { #[test] fn test_route_container_create_image_not_found() { let router = Router::new(make_manager(vec!["alpine"])); - let map: HashMap = serde_json::from_value( - serde_json::json!({"Image": "ubuntu:latest"}), - ).unwrap(); + let map: HashMap = + serde_json::from_value(serde_json::json!({"Image": "ubuntu:latest"})).unwrap(); let result = router.route("POST", "/containers/create", Some(&map)); assert_eq!(result.action, Action::Deny); assert!(result.deny_msg.unwrap().contains("not allowed by any policy")); @@ -562,7 +556,12 @@ mod tests { // #52: dotted version, container lifecycle start. ("POST", "/v1.43/containers/beacon/start", None, Action::Allow), // #52: dotted version, create with a policy-allowed image. - ("POST", "/v1.43/containers/create", Some(&create_body), Action::CreateContainer), + ( + "POST", + "/v1.43/containers/create", + Some(&create_body), + Action::CreateContainer, + ), // #52: undotted control — stripped correctly everywhere already. ("POST", "/v1/containers/beacon/start", None, Action::Allow), // #52: the reserved segment survives the strip (#24 parity). @@ -678,7 +677,13 @@ mod tests { // execNamespacePostDeniedTest (#49) ("POST", "/exec/abc/start", None, Action::Deny, exec), // createSubpathDeniedTest (#49): an allowed image, so only the path decides. - ("POST", "/containers/create/extra", Some(&create_body), Action::Deny, None), + ( + "POST", + "/containers/create/extra", + Some(&create_body), + Action::Deny, + None, + ), // Exec is matched under containers or exec only (#49, language-only). ("GET", "/images/exec", None, Action::Allow, None), // An allowed image, so only the path decides (#49, language-only). @@ -694,7 +699,11 @@ mod tests { ]; for (method, path, body, want, want_msg) in cases { let got = router.route(method, path, body); - assert_eq!(got.action, want, "route({} {}) deny msg = {:?}", method, path, got.deny_msg); + assert_eq!( + got.action, want, + "route({} {}) deny msg = {:?}", + method, path, got.deny_msg + ); // Exact match: the default deny for POST /containers/x/exec ends in // "exec is not allowed" too, so a substring check passes vacuously. if let Some(want_msg) = want_msg { @@ -713,9 +722,8 @@ mod tests { #[test] fn test_route_image_pull() { let router = Router::new(make_manager(vec!["alpine"])); - let map: HashMap = serde_json::from_value( - serde_json::json!({"fromImage": "alpine"}), - ).unwrap(); + let map: HashMap = + serde_json::from_value(serde_json::json!({"fromImage": "alpine"})).unwrap(); let result = router.route("POST", "/images/create", Some(&map)); assert_eq!(result.action, Action::Allow); assert_eq!(result.image, Some("alpine".into())); @@ -731,9 +739,8 @@ mod tests { #[test] fn test_route_image_pull_no_fromimage() { let router = Router::new(make_manager(vec!["alpine"])); - let map: HashMap = serde_json::from_value( - serde_json::json!({"tag": "latest"}), - ).unwrap(); + let map: HashMap = + serde_json::from_value(serde_json::json!({"tag": "latest"})).unwrap(); let result = router.route("POST", "/images/create", Some(&map)); assert_eq!(result.action, Action::Deny); assert!(result.deny_msg.unwrap().contains("fromImage")); @@ -742,9 +749,8 @@ mod tests { #[test] fn test_route_image_pull_not_found() { let router = Router::new(make_manager(vec!["alpine"])); - let map: HashMap = serde_json::from_value( - serde_json::json!({"fromImage": "ubuntu"}), - ).unwrap(); + let map: HashMap = + serde_json::from_value(serde_json::json!({"fromImage": "ubuntu"})).unwrap(); let result = router.route("POST", "/images/create", Some(&map)); assert_eq!(result.action, Action::Deny); assert!(result.deny_msg.unwrap().contains("not allowed by any policy")); @@ -790,9 +796,8 @@ mod tests { #[test] fn test_container_create_body_passthrough() { let router = Router::new(make_manager(vec!["alpine"])); - let map: HashMap = serde_json::from_value( - serde_json::json!({"Image": "alpine", "Cmd": ["sleep", "100"]}), - ).unwrap(); + let map: HashMap = + serde_json::from_value(serde_json::json!({"Image": "alpine", "Cmd": ["sleep", "100"]})).unwrap(); let result = router.route("POST", "/containers/create", Some(&map)); assert_eq!(result.action, Action::CreateContainer); let b = result.body.unwrap(); diff --git a/rs/src/transport.rs b/rs/src/transport.rs index 3dde74b..43a4684 100644 --- a/rs/src/transport.rs +++ b/rs/src/transport.rs @@ -28,10 +28,7 @@ impl std::error::Error for TransportError {} #[async_trait] pub trait Transport: Send + Sync { - async fn forward( - &self, - req: Request>, - ) -> Result>, TransportError>; + async fn forward(&self, req: Request>) -> Result>, TransportError>; } pub struct UnixSocketTransport { @@ -55,10 +52,7 @@ fn map_connect_error(e: io::Error) -> TransportError { #[async_trait] impl Transport for UnixSocketTransport { - async fn forward( - &self, - req: Request>, - ) -> Result>, TransportError> { + async fn forward(&self, req: Request>) -> Result>, TransportError> { let stream = match tokio::net::UnixStream::connect(&self.docker_host).await { Ok(s) => s, Err(e) => return Err(map_connect_error(e)), @@ -98,10 +92,7 @@ mod tests { #[tokio::test] async fn test_forward_missing_socket_errors() { let transport = UnixSocketTransport::new("/nonexistent/docker.sock"); - let req = Request::builder() - .uri("/_ping") - .body(Full::new(Bytes::new())) - .unwrap(); + let req = Request::builder().uri("/_ping").body(Full::new(Bytes::new())).unwrap(); let res = transport.forward(req).await; assert!(res.is_err(), "expected forwarding to a missing socket to fail"); assert!(!matches!(res, Err(TransportError::SocketPermission(_)))); @@ -118,4 +109,4 @@ mod tests { let conn_refused = io::Error::from(io::ErrorKind::ConnectionRefused); assert!(matches!(map_connect_error(conn_refused), TransportError::Other(_))); } -} \ No newline at end of file +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 17e0e19..1b46e94 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,4 @@ [toolchain] channel = "1.85" targets = ["x86_64-unknown-linux-musl"] +components = ["rustfmt"] diff --git a/ts/.prettierrc b/ts/.prettierrc new file mode 100644 index 0000000..0b377a4 --- /dev/null +++ b/ts/.prettierrc @@ -0,0 +1 @@ +{ "printWidth": 120 } diff --git a/ts/package-lock.json b/ts/package-lock.json index c14fc2a..3941ac0 100644 --- a/ts/package-lock.json +++ b/ts/package-lock.json @@ -12,6 +12,7 @@ }, "devDependencies": { "@types/node": "^22.0.0", + "prettier": "3.9.9", "typescript": "^5.7.0" } }, @@ -25,6 +26,22 @@ "undici-types": "~6.21.0" } }, + "node_modules/prettier": { + "version": "3.9.9", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", + "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/ts/package.json b/ts/package.json index deead2e..280383c 100644 --- a/ts/package.json +++ b/ts/package.json @@ -5,13 +5,15 @@ "scripts": { "build": "tsc", "start": "node dist/index.js", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "format:check": "prettier --check src" }, "dependencies": { "yaml": "^2.7.0" }, "devDependencies": { "@types/node": "^22.0.0", + "prettier": "3.9.9", "typescript": "^5.7.0" } } diff --git a/ts/src/audit.test.ts b/ts/src/audit.test.ts index 60fbd26..db58fa8 100644 --- a/ts/src/audit.test.ts +++ b/ts/src/audit.test.ts @@ -61,4 +61,4 @@ describe("AuditLogger", () => { const logger = new AuditLogger("/nonexistent-dir/audit.log"); assert.doesNotThrow(() => logger.allow("GET", "/_ping")); }); -}); \ No newline at end of file +}); diff --git a/ts/src/flags.test.ts b/ts/src/flags.test.ts index a664060..3b49a9f 100644 --- a/ts/src/flags.test.ts +++ b/ts/src/flags.test.ts @@ -28,10 +28,7 @@ describe("flags", () => { }); it("prefers first occurrence", () => { - assert.equal( - getFlag(["--config-dir=/a", "--config-dir", "/b"], "--config-dir", "d"), - "/a", - ); + assert.equal(getFlag(["--config-dir=/a", "--config-dir", "/b"], "--config-dir", "d"), "/a"); }); it("returns default when absent", () => { @@ -44,17 +41,11 @@ describe("flags", () => { }); it("does not match a different flag's value", () => { - assert.equal( - getFlag(["--listen-socket", "/run/dsp.sock"], "--config-dir", "d"), - "d", - ); + assert.equal(getFlag(["--listen-socket", "/run/dsp.sock"], "--config-dir", "d"), "d"); }); it("handles --name=value where value contains equals", () => { - assert.equal( - getFlag(["--log-file=/a=/b"], "--log-file", "d"), - "/a=/b", - ); + assert.equal(getFlag(["--log-file=/a=/b"], "--log-file", "d"), "/a=/b"); }); }); @@ -307,9 +298,7 @@ describe("selectSocketGroup", () => { // the expression index.ts uses. describe("--listen-socket-group empty vs absent", () => { const groupFlag = (args: string[]) => - hasFlag(args, "--listen-socket-group") - ? getFlag(args, "--listen-socket-group", "") - : undefined; + hasFlag(args, "--listen-socket-group") ? getFlag(args, "--listen-socket-group", "") : undefined; it("absent", () => { assert.equal(groupFlag([]), undefined); diff --git a/ts/src/flags.ts b/ts/src/flags.ts index 0be7280..428e54f 100644 --- a/ts/src/flags.ts +++ b/ts/src/flags.ts @@ -3,11 +3,7 @@ import { readFileSync } from "node:fs"; -export function getFlag( - args: string[], - name: string, - defaultVal: string, -): string { +export function getFlag(args: string[], name: string, defaultVal: string): string { const prefix = name + "="; for (let i = 0; i < args.length; i++) { if (args[i] === name && i + 1 < args.length) return args[i + 1]; @@ -22,13 +18,7 @@ export function hasFlag(args: string[], name: string): boolean { } // The flags the proxy accepts, for validateFlags. -export const VALUE_FLAGS = [ - "--listen-socket", - "--docker-host", - "--config-dir", - "--log-file", - "--listen-socket-group", -]; +export const VALUE_FLAGS = ["--listen-socket", "--docker-host", "--config-dir", "--log-file", "--listen-socket-group"]; export const BOOL_FLAGS = ["--readonly"]; // Rejects anything not recognised. Go's flag package and Rust's clap both exit @@ -40,11 +30,7 @@ export const BOOL_FLAGS = ["--readonly"]; // `valueFlags` take an argument (in either `--name value` or `--name=value` // form); `boolFlags` do not, and must not swallow the following argument. // Returns an error message, or null when every argument is recognised. -export function validateFlags( - args: string[], - valueFlags: string[], - boolFlags: string[], -): string | null { +export function validateFlags(args: string[], valueFlags: string[], boolFlags: string[]): string | null { const known = [...valueFlags, ...boolFlags]; for (let i = 0; i < args.length; i++) { const arg = args[i]; @@ -69,9 +55,7 @@ export function validateFlags( // Where the proxy should listen. The proxy listens on a Unix socket only: // filesystem ownership on that socket is the access-control boundary, and a // TCP listener would carry no peer identity at all. -export type ListenTarget = - | { kind: "path"; path: string } - | { kind: "error"; message: string }; +export type ListenTarget = { kind: "path"; path: string } | { kind: "error"; message: string }; // Parses --listen-socket, which must be an absolute filesystem path. Mirrors // validateListenSocket in Go and validate_listen_socket in Rust. diff --git a/ts/src/handler.test.ts b/ts/src/handler.test.ts index d703a6e..920bb18 100644 --- a/ts/src/handler.test.ts +++ b/ts/src/handler.test.ts @@ -45,9 +45,7 @@ function makeRequest(method: string, url: string, body?: string): IncomingMessag const stream = Readable.from(body ? [Buffer.from(body)] : []); (stream as IncomingMessage).method = method; (stream as IncomingMessage).url = url; - (stream as IncomingMessage).headers = body - ? { "content-length": String(Buffer.byteLength(body)) } - : {}; + (stream as IncomingMessage).headers = body ? { "content-length": String(Buffer.byteLength(body)) } : {}; return stream as IncomingMessage; } @@ -117,10 +115,7 @@ describe("Handler", () => { const dir = makeEnv(defaultConfig); const { handler, transport } = newHandler(dir); const { res, recorder } = makeResponse(); - await handler.handle( - makeRequest("POST", "/containers/create", JSON.stringify({ Image: "ubuntu:latest" })), - res, - ); + await handler.handle(makeRequest("POST", "/containers/create", JSON.stringify({ Image: "ubuntu:latest" })), res); assert.equal(recorder.statusCode, 403); assert.equal(transport.lastRequest, undefined); }); @@ -191,4 +186,4 @@ describe("Handler", () => { assert.ok(transport.lastRequest, "expected request to be forwarded"); assert.equal(transport.lastRequest.url, url); }); -}); \ No newline at end of file +}); diff --git a/ts/src/index.ts b/ts/src/index.ts index 2235201..745af6e 100644 --- a/ts/src/index.ts +++ b/ts/src/index.ts @@ -95,9 +95,7 @@ server.once("listening", () => { const path = listenTarget.path; openListener(server, path, socketGid).then( () => { - console.log( - `listening on unix socket ${path} (mode ${SOCKET_MODE.toString(8).padStart(4, "0")})`, - ); + console.log(`listening on unix socket ${path} (mode ${SOCKET_MODE.toString(8).padStart(4, "0")})`); }, (err: NodeJS.ErrnoException) => { console.error(`failed to listen on ${path}: ${err.message}`); diff --git a/ts/src/listen.test.ts b/ts/src/listen.test.ts index 895b4d1..2a4caf6 100644 --- a/ts/src/listen.test.ts +++ b/ts/src/listen.test.ts @@ -56,16 +56,8 @@ describe("listenOnSocket", () => { process.umask(previous); } - assert.equal( - modeOf(path), - 0o660, - `socket mode was ${modeOf(path).toString(8)} under umask 0, want 660`, - ); - assert.equal( - modeOf(path) & 0o002, - 0, - "socket is world-writable: any local uid could connect", - ); + assert.equal(modeOf(path), 0o660, `socket mode was ${modeOf(path).toString(8)} under umask 0, want 660`); + assert.equal(modeOf(path) & 0o002, 0, "socket is world-writable: any local uid could connect"); await new Promise((r) => server.close(r)); cleanup(); @@ -87,10 +79,7 @@ describe("listenOnSocket", () => { const before = process.umask(); const server = createServer(() => {}); - await assert.rejects( - () => listenOnSocket(server, "/nonexistent-dir-xyz/s.sock"), - /ENOENT|EACCES/, - ); + await assert.rejects(() => listenOnSocket(server, "/nonexistent-dir-xyz/s.sock"), /ENOENT|EACCES/); assert.equal(process.umask(), before, "umask was not restored after a failed bind"); }); @@ -292,16 +281,10 @@ describe("openListener", () => { const path = join(dir, `c${i}.sock`); const servers = Array.from({ length: racers }, () => createNetServer()); try { - const results = await Promise.allSettled( - servers.map((s) => openListener(s, path, process.getegid!())), - ); + const results = await Promise.allSettled(servers.map((s) => openListener(s, path, process.getegid!()))); const winners = results.flatMap((r, j) => (r.status === "fulfilled" ? [j] : [])); - assert.equal( - winners.length, - 1, - `iteration ${i}: ${winners.length} openListener calls succeeded, want 1`, - ); + assert.equal(winners.length, 1, `iteration ${i}: ${winners.length} openListener calls succeeded, want 1`); for (const r of results) { if (r.status === "rejected") { assert.match( diff --git a/ts/src/listen.ts b/ts/src/listen.ts index 270bf7b..fb29cb3 100644 --- a/ts/src/listen.ts +++ b/ts/src/listen.ts @@ -24,11 +24,7 @@ import { BIND_UMASK, SOCKET_MODE } from "./flags.js"; * main always passes the gid chosen by selectSocketGroup; an undefined gid * skips the chown and exists only for tests. */ -export function listenOnSocket( - server: Server, - path: string, - gid?: number, -): Promise { +export function listenOnSocket(server: Server, path: string, gid?: number): Promise { return new Promise((resolve, reject) => { // umask is process-global. This runs during startup, before any request is // served, so nothing else is creating files in the window. diff --git a/ts/src/middleware.test.ts b/ts/src/middleware.test.ts index dbf6eb9..67b7f54 100644 --- a/ts/src/middleware.test.ts +++ b/ts/src/middleware.test.ts @@ -58,7 +58,12 @@ describe("RegistryGate", () => { }); it("allows image matching prefix with org", () => { - assert.equal(gate.check("POST", "/containers/create", makePolicy({ allowed_image_prefixes: ["myorg"] }), { Image: "myorg/app:v1" }), null); + assert.equal( + gate.check("POST", "/containers/create", makePolicy({ allowed_image_prefixes: ["myorg"] }), { + Image: "myorg/app:v1", + }), + null, + ); }); it("denies image not in allowed prefixes", () => { @@ -142,11 +147,22 @@ describe("MountSourceGate", () => { }); it("returns null when no volumes in policy", () => { - assert.equal(gate.check("POST", "/containers/create", makePolicy(), { HostConfig: { Binds: ["/data:/data"] } }), null); + assert.equal( + gate.check("POST", "/containers/create", makePolicy(), { HostConfig: { Binds: ["/data:/data"] } }), + null, + ); }); it("returns null when no Binds", () => { - assert.equal(gate.check("POST", "/containers/create", makePolicy({ volumes: [{ host_path: "/data", container_path: "/data", read_write: true }] }), {}), null); + assert.equal( + gate.check( + "POST", + "/containers/create", + makePolicy({ volumes: [{ host_path: "/data", container_path: "/data", read_write: true }] }), + {}, + ), + null, + ); }); it("allows top-level Volumes in whitelist", () => { diff --git a/ts/src/policy.test.ts b/ts/src/policy.test.ts index fead3fc..c000963 100644 --- a/ts/src/policy.test.ts +++ b/ts/src/policy.test.ts @@ -17,10 +17,7 @@ describe("Manager", () => { }); it("loads policies from YAML files", () => { - writeFileSync( - join(tmpDir, "test.yaml"), - "service_name: test-service\nallowed_image_prefixes:\n - nginx\n", - ); + writeFileSync(join(tmpDir, "test.yaml"), "service_name: test-service\nallowed_image_prefixes:\n - nginx\n"); const m = new Manager(tmpDir); assert.equal(m.list().length, 1); assert.ok(m.list().includes("test-service")); @@ -34,10 +31,7 @@ describe("Manager", () => { }); it("loads .yml extension", () => { - writeFileSync( - join(tmpDir, "test.yml"), - "service_name: my-service\nallowed_image_prefixes:\n - busybox\n", - ); + writeFileSync(join(tmpDir, "test.yml"), "service_name: my-service\nallowed_image_prefixes:\n - busybox\n"); const m = new Manager(tmpDir); assert.equal(m.list().length, 1); }); @@ -53,10 +47,7 @@ describe("Manager", () => { }); it("get returns policy by service name", () => { - writeFileSync( - join(tmpDir, "svc.yaml"), - "service_name: my-svc\nallowed_image_prefixes:\n - nginx\n", - ); + writeFileSync(join(tmpDir, "svc.yaml"), "service_name: my-svc\nallowed_image_prefixes:\n - nginx\n"); const m = new Manager(tmpDir); const p = m.get("my-svc"); assert.ok(p); @@ -65,19 +56,13 @@ describe("Manager", () => { }); it("get returns undefined for unknown service", () => { - writeFileSync( - join(tmpDir, "svc.yaml"), - "service_name: known\nallowed_image_prefixes:\n - nginx\n", - ); + writeFileSync(join(tmpDir, "svc.yaml"), "service_name: known\nallowed_image_prefixes:\n - nginx\n"); const m = new Manager(tmpDir); assert.equal(m.get("unknown"), undefined); }); it("getByImage matches exact prefix", () => { - writeFileSync( - join(tmpDir, "svc.yaml"), - "service_name: nginx-svc\nallowed_image_prefixes:\n - nginx\n", - ); + writeFileSync(join(tmpDir, "svc.yaml"), "service_name: nginx-svc\nallowed_image_prefixes:\n - nginx\n"); const m = new Manager(tmpDir); const p = m.getByImage("nginx:latest"); assert.ok(p); @@ -85,10 +70,7 @@ describe("Manager", () => { }); it("getByImage matches prefix with slash", () => { - writeFileSync( - join(tmpDir, "svc.yaml"), - "service_name: my-org\nallowed_image_prefixes:\n - myorg\n", - ); + writeFileSync(join(tmpDir, "svc.yaml"), "service_name: my-org\nallowed_image_prefixes:\n - myorg\n"); const m = new Manager(tmpDir); const p = m.getByImage("myorg/web-app:v1"); assert.ok(p); @@ -96,10 +78,7 @@ describe("Manager", () => { }); it("getByImage returns undefined when no prefix matches", () => { - writeFileSync( - join(tmpDir, "svc.yaml"), - "service_name: svc\nallowed_image_prefixes:\n - nginx\n", - ); + writeFileSync(join(tmpDir, "svc.yaml"), "service_name: svc\nallowed_image_prefixes:\n - nginx\n"); const m = new Manager(tmpDir); assert.equal(m.getByImage("redis:latest"), undefined); }); diff --git a/ts/src/proxy.test.ts b/ts/src/proxy.test.ts index 80187ec..2bec613 100644 --- a/ts/src/proxy.test.ts +++ b/ts/src/proxy.test.ts @@ -7,10 +7,7 @@ import { Manager } from "./policy.js"; import { Action, Router } from "./proxy.js"; function createManager(dir: string) { - writeFileSync( - join(dir, "nginx.yaml"), - "service_name: nginx-svc\nallowed_image_prefixes:\n - nginx\n", - ); + writeFileSync(join(dir, "nginx.yaml"), "service_name: nginx-svc\nallowed_image_prefixes:\n - nginx\n"); writeFileSync( join(dir, "redis.yaml"), "service_name: redis-svc\nallowed_image_prefixes:\n - redis\ncontainer_config:\n network_mode: bridge\nvolumes:\n - host_path: /data\n container_path: /data\n read_write: true\n", diff --git a/ts/src/proxy.ts b/ts/src/proxy.ts index 1f321af..d249eb4 100644 --- a/ts/src/proxy.ts +++ b/ts/src/proxy.ts @@ -53,8 +53,10 @@ export class Router { if (path.endsWith("/wait") && method === "POST") return this.routeByName(containerName); if (path.endsWith("/pause") && method === "POST") return this.routeByName(containerName); if (path.endsWith("/unpause") && method === "POST") return this.routeByName(containerName); - if (path.endsWith("/rename") && method === "POST") return { action: Action.Deny, denyMsg: "rename is not allowed" }; - if (path.endsWith("/update") && method === "POST") return { action: Action.Deny, denyMsg: "update is not allowed" }; + if (path.endsWith("/rename") && method === "POST") + return { action: Action.Deny, denyMsg: "rename is not allowed" }; + if (path.endsWith("/update") && method === "POST") + return { action: Action.Deny, denyMsg: "update is not allowed" }; if (method === "DELETE") return this.routeByName(containerName); if (method === "GET") return { action: Action.Allow }; } diff --git a/ts/src/shutdown.test.ts b/ts/src/shutdown.test.ts index bf46fb0..f16b8e9 100644 --- a/ts/src/shutdown.test.ts +++ b/ts/src/shutdown.test.ts @@ -1,11 +1,6 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { - Agent, - createServer, - request as httpRequest, - type Server, -} from "node:http"; +import { Agent, createServer, request as httpRequest, type Server } from "node:http"; import { connect } from "node:net"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -59,12 +54,7 @@ async function harness( } function withTimeout(p: Promise, ms: number, msg: string): Promise { - return Promise.race([ - p, - new Promise((_, reject) => - setTimeout(() => reject(new Error(msg)), ms).unref(), - ), - ]); + return Promise.race([p, new Promise((_, reject) => setTimeout(() => reject(new Error(msg)), ms).unref())]); } /** Opens a connection and sends a request, returning the raw reply. */ @@ -105,24 +95,17 @@ describe("createShutdown", () => { // client leaves behind between API calls. const agent = new Agent({ keepAlive: true }); await new Promise((resolve, reject) => { - const req = httpRequest( - { socketPath: h.path, path: "/first", agent }, - (res) => { - res.resume(); - res.on("end", () => resolve()); - }, - ); + const req = httpRequest({ socketPath: h.path, path: "/first", agent }, (res) => { + res.resume(); + res.on("end", () => resolve()); + }); req.on("error", reject); req.end(); }); const started = Date.now(); h.shutdown("SIGTERM"); - const code = await withTimeout( - h.exited, - 5_000, - "idle keep-alive connection held shutdown open", - ); + const code = await withTimeout(h.exited, 5_000, "idle keep-alive connection held shutdown open"); const elapsed = Date.now() - started; assert.equal(code, 0); diff --git a/ts/src/shutdown.ts b/ts/src/shutdown.ts index 7468237..61fc059 100644 --- a/ts/src/shutdown.ts +++ b/ts/src/shutdown.ts @@ -25,10 +25,7 @@ export interface ShutdownOptions { * never completes would block exit forever, with no equivalent of Go's or * Rust's 30s cap. */ -export function createShutdown( - server: Server, - options: ShutdownOptions = {}, -): (signal: string) => void { +export function createShutdown(server: Server, options: ShutdownOptions = {}): (signal: string) => void { const timeoutMs = options.timeoutMs ?? SHUTDOWN_TIMEOUT_MS; const log = options.log ?? ((m: string) => console.log(m)); const error = options.error ?? ((m: string) => console.error(m));