Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions .githooks/pre-push
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
22 changes: 19 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────

Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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) ──────────────────

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
2 changes: 1 addition & 1 deletion go/internal/audit/audit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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])
}
}
}
2 changes: 1 addition & 1 deletion go/internal/proxy/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import (
type Action int

const (
ActionDeny Action = iota
ActionDeny Action = iota
ActionAllow
ActionCreateContainer
)
Expand Down
2 changes: 1 addition & 1 deletion go/internal/proxy/transport_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
func (e *osSyscallError) Unwrap() error { return e.err }
1 change: 1 addition & 0 deletions rs/rustfmt.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
max_width = 120
13 changes: 3 additions & 10 deletions rs/src/audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,8 @@ pub struct AuditLogger {

impl AuditLogger {
pub fn new(path: &str) -> Result<Self, Box<dyn std::error::Error>> {
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 {
Expand All @@ -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) {
Expand Down
60 changes: 34 additions & 26 deletions rs/src/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -20,7 +20,12 @@ pub struct Handler {

impl Handler {
pub fn new(router: Arc<Router>, chain: Chain, audit: AuditLogger, transport: Box<dyn Transport>) -> Self {
Handler { router, chain, audit, transport }
Handler {
router,
chain,
audit,
transport,
}
}

pub async fn handle<B>(&self, req: Request<B>) -> Response<Full<Bytes>>
Expand Down Expand Up @@ -130,11 +135,18 @@ mod tests {
}

impl MockTransport {
fn new() -> (Self, Arc<std::sync::Mutex<Option<Bytes>>>, Arc<std::sync::Mutex<Option<hyper::HeaderMap>>>) {
fn new() -> (
Self,
Arc<std::sync::Mutex<Option<Bytes>>>,
Arc<std::sync::Mutex<Option<hyper::HeaderMap>>>,
) {
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,
)
Expand All @@ -143,10 +155,7 @@ mod tests {

#[async_trait]
impl Transport for MockTransport {
async fn forward(
&self,
req: Request<Full<Bytes>>,
) -> Result<Response<Full<Bytes>>, TransportError> {
async fn forward(&self, req: Request<Full<Bytes>>) -> Result<Response<Full<Bytes>>, TransportError> {
*self.captured_headers.lock().unwrap() = Some(req.headers().clone());
let body = req.into_body();
let collected = http_body_util::BodyExt::collect(body)
Expand All @@ -161,7 +170,11 @@ mod tests {
}
}

fn make_test_handler() -> (Handler, Arc<std::sync::Mutex<Option<Bytes>>>, Arc<std::sync::Mutex<Option<hyper::HeaderMap>>>) {
fn make_test_handler() -> (
Handler,
Arc<std::sync::Mutex<Option<Bytes>>>,
Arc<std::sync::Mutex<Option<hyper::HeaderMap>>>,
) {
use crate::policy::ContainerConfig;
let mut policies = std::collections::HashMap::new();
policies.insert(
Expand Down Expand Up @@ -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)));
Expand Down Expand Up @@ -244,17 +256,14 @@ mod tests {

#[async_trait]
impl Transport for FailingTransport {
async fn forward(
&self,
_req: Request<Full<Bytes>>,
) -> Result<Response<Full<Bytes>>, TransportError> {
async fn forward(&self, _req: Request<Full<Bytes>>) -> Result<Response<Full<Bytes>>, TransportError> {
match self.kind {
FailureKind::SocketPermission => Err(TransportError::SocketPermission(
std::io::Error::from(std::io::ErrorKind::PermissionDenied),
)),
FailureKind::Other => Err(TransportError::Other(
Box::<dyn std::error::Error + Send + Sync>::from("connection refused"),
)),
FailureKind::SocketPermission => Err(TransportError::SocketPermission(std::io::Error::from(
std::io::ErrorKind::PermissionDenied,
))),
FailureKind::Other => Err(TransportError::Other(Box::<dyn std::error::Error + Send + Sync>::from(
"connection refused",
))),
}
}
}
Expand Down Expand Up @@ -373,10 +382,7 @@ mod tests {

#[async_trait]
impl Transport for UriRecordingTransport {
async fn forward(
&self,
req: Request<Full<Bytes>>,
) -> Result<Response<Full<Bytes>>, TransportError> {
async fn forward(&self, req: Request<Full<Bytes>>) -> Result<Response<Full<Bytes>>, TransportError> {
*self.captured_uri.lock().unwrap() = Some(req.uri().clone());
Ok(Response::builder()
.status(StatusCode::OK)
Expand All @@ -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)
}

Expand Down
Loading
Loading