Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 3 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ permissions:
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: -Dwarnings
RUST_BACKTRACE: "1"

jobs:
actionlint:
Expand Down Expand Up @@ -142,10 +143,10 @@ jobs:
echo "OK: no libdbus dependency"

- name: Run tests
run: cargo test --workspace --verbose
run: cargo test --workspace --verbose --no-fail-fast

- name: Run doc tests
run: cargo test --doc --workspace --verbose
run: cargo test --doc --workspace --verbose --no-fail-fast

fmt:
name: Rustfmt
Expand Down
46 changes: 39 additions & 7 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@
# make check Run clippy and format check
# make release Build release binaries

.PHONY: all build build-lib build-cli build-ffi build-arm64 test test-lib test-cli test-ffi test-spiffe check clippy fmt clean install audit help
.PHONY: all build build-lib build-cli build-ffi build-arm64 test test-workspace test-lib test-cli test-ffi test-doc test-one test-repeat test-spiffe check clippy fmt clean install audit help

# Keep Rust panic locations available in both local and CI test output. A
# caller can still override this (for example, RUST_BACKTRACE=full make test).
RUST_BACKTRACE ?= 1

# Default target
all: build
Expand Down Expand Up @@ -40,19 +44,44 @@ build-arm64:
@cross build --release --target aarch64-unknown-linux-gnu -p nono-cli

# Test targets
test: test-lib test-cli test-ffi
#
# Match the regular Rust CI coverage. The former package-by-package aggregate
# omitted nono-proxy and any future workspace members.
test: test-workspace

test-workspace:
RUST_BACKTRACE=$(RUST_BACKTRACE) cargo test --workspace --no-fail-fast

test-lib:
cargo test -p nono
RUST_BACKTRACE=$(RUST_BACKTRACE) cargo test -p nono --no-fail-fast

test-cli:
cargo test -p nono-cli
RUST_BACKTRACE=$(RUST_BACKTRACE) cargo test -p nono-cli --no-fail-fast

test-ffi:
cargo test -p nono-ffi
RUST_BACKTRACE=$(RUST_BACKTRACE) cargo test -p nono-ffi --no-fail-fast

test-doc:
cargo test --doc
RUST_BACKTRACE=$(RUST_BACKTRACE) cargo test --doc --workspace --no-fail-fast

# Run one exact test with live process output. Usage:
# make test-one TEST=approval_runtime::tests::platform_poll_loop_gives_up_at_the_configured_timeout
test-one:
@test -n "$(TEST)" || { echo "Usage: make test-one TEST=<exact-test-name>" >&2; exit 2; }
RUST_BACKTRACE=$(RUST_BACKTRACE) cargo test --workspace --no-fail-fast "$(TEST)" -- --exact --nocapture --test-threads=1

# Repeat one exact test serially to reproduce intermittent failures. Usage:
# make test-repeat TEST=<exact-test-name> COUNT=100
COUNT ?= 20
test-repeat:
@test -n "$(TEST)" || { echo "Usage: make test-repeat TEST=<exact-test-name> [COUNT=<iterations>]" >&2; exit 2; }
@set -eu; \
i=1; \
while [ "$$i" -le "$(COUNT)" ]; do \
echo "==> $$i/$(COUNT): $(TEST)"; \
RUST_BACKTRACE=$(RUST_BACKTRACE) cargo test --workspace --no-fail-fast "$(TEST)" -- --exact --nocapture --test-threads=1; \
i=$$((i + 1)); \
done

test-spiffe:
bash scripts/spire-test.sh
Expand Down Expand Up @@ -115,7 +144,7 @@ lint-docs:
bash scripts/lint-docs.sh

# CI simulation (what CI would run)
ci: check test audit lint-docs
ci: check test test-doc audit lint-docs
@echo "CI checks passed"

# Help
Expand All @@ -132,10 +161,13 @@ help:
@echo ""
@echo "Test:"
@echo " make test Run all tests"
@echo " make test-workspace Run all workspace Rust tests"
@echo " make test-lib Run library tests only"
@echo " make test-cli Run CLI tests only"
@echo " make test-ffi Run C FFI tests only"
@echo " make test-doc Run doc tests only"
@echo " make test-one TEST=<name> Run one exact Rust test with live output"
@echo " make test-repeat TEST=<name> COUNT=N Repeat one exact Rust test serially"
@echo " make test-spiffe Run SPIFFE/SPIRE integration tests (downloads SPIRE if needed)"
@echo ""
@echo "Check:"
Expand Down
70 changes: 49 additions & 21 deletions crates/nono-cli/src/approval_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -279,12 +279,12 @@ impl WebhookApproval {
}
}

fn http_status_denial(&self, status: u16, started: Instant) -> ApprovalDecision {
fn http_status_denial(&self, status: u16, elapsed: Duration) -> ApprovalDecision {
ApprovalDecision::Denied {
reason: format!(
"approval webhook '{}' returned HTTP {status} after {}s",
self.name,
started.elapsed().as_secs()
elapsed.as_secs()
),
}
}
Expand Down Expand Up @@ -327,7 +327,7 @@ impl WebhookApproval {
let status = response.status().as_u16();
let response_body = self.read_body(&mut response)?;
if !(200..300).contains(&status) {
return Ok(self.http_status_denial(status, started));
return Ok(self.http_status_denial(status, started.elapsed()));
}
self.parse_response(&response_body)
}
Expand All @@ -342,11 +342,24 @@ impl WebhookApproval {
request_id: &str,
body: &[u8],
started: Instant,
send: impl FnMut(PlatformStep<'_>, Duration) -> Result<HttpReply>,
) -> Result<ApprovalDecision> {
self.drive_platform_approval_with_elapsed(request_id, body, || started.elapsed(), send)
}

/// The poll-loop state machine with an injected monotonic elapsed-time
/// source. Keeping time separate from transport lets its deadline behavior
/// be tested without wall-clock sleeps or scheduler-dependent assertions.
fn drive_platform_approval_with_elapsed(
&self,
request_id: &str,
body: &[u8],
mut elapsed: impl FnMut() -> Duration,
mut send: impl FnMut(PlatformStep<'_>, Duration) -> Result<HttpReply>,
) -> Result<ApprovalDecision> {
let mut submitted = false;
loop {
let remaining = self.timeout.saturating_sub(started.elapsed());
let remaining = self.timeout.saturating_sub(elapsed());
if remaining.is_zero() {
return Ok(ApprovalDecision::Timeout);
}
Expand All @@ -359,11 +372,12 @@ impl WebhookApproval {
// The deadline is checked again after the round trip: a decision
// that arrives once this backend's timeout has elapsed is never
// applied, however long the individual request took.
if started.elapsed() >= self.timeout {
let elapsed_after_reply = elapsed();
if elapsed_after_reply >= self.timeout {
return Ok(ApprovalDecision::Timeout);
}
if !(200..300).contains(&reply.status) {
return Ok(self.http_status_denial(reply.status, started));
return Ok(self.http_status_denial(reply.status, elapsed_after_reply));
}
let status: PlatformApprovalStatus =
serde_json::from_str(&reply.body).map_err(|e| {
Expand Down Expand Up @@ -825,32 +839,46 @@ mod tests {
#[test]
fn platform_poll_loop_gives_up_at_the_configured_timeout() {
let backend = test_webhook(Duration::from_millis(40));
let elapsed = std::rc::Rc::new(std::cell::Cell::new(Duration::ZERO));
let clock = std::rc::Rc::clone(&elapsed);
let mut polls = 0;
let decision = backend
.drive_platform_approval("req-2", b"{}", Instant::now(), |_step, _remaining| {
polls += 1;
std::thread::sleep(Duration::from_millis(10));
Ok(reply(202, r#"{"state":"pending"}"#))
})
.drive_platform_approval_with_elapsed(
"req-2",
b"{}",
|| clock.get(),
|_step, _remaining| {
polls += 1;
elapsed.set(elapsed.get() + Duration::from_millis(10));
Ok(reply(202, r#"{"state":"pending"}"#))
},
)
.unwrap();
assert!(matches!(decision, ApprovalDecision::Timeout));
assert!(
polls >= 2,
"expected several polls before giving up, saw {polls}"
assert_eq!(
polls, 4,
"the fourth 10 ms reply reaches the 40 ms deadline"
);
}

#[test]
fn platform_poll_loop_rejects_a_grant_that_arrives_after_the_deadline() {
let backend = test_webhook(Duration::from_millis(40));
let elapsed = std::rc::Rc::new(std::cell::Cell::new(Duration::ZERO));
let clock = std::rc::Rc::clone(&elapsed);
let decision = backend
.drive_platform_approval("req-late", b"{}", Instant::now(), |_, remaining| {
// The hop is handed only the remaining budget...
assert!(remaining <= Duration::from_millis(40));
// ...but simulate a server that answers after the deadline.
std::thread::sleep(Duration::from_millis(60));
Ok(reply(200, r#"{"state":"granted","decision":"granted"}"#))
})
.drive_platform_approval_with_elapsed(
"req-late",
b"{}",
|| clock.get(),
|_, remaining| {
// The hop is handed only the remaining budget...
assert_eq!(remaining, Duration::from_millis(40));
// ...but a server response can still arrive after it.
elapsed.set(Duration::from_millis(60));
Ok(reply(200, r#"{"state":"granted","decision":"granted"}"#))
},
)
.unwrap();
assert!(
matches!(decision, ApprovalDecision::Timeout),
Expand Down
103 changes: 33 additions & 70 deletions scripts/run_all_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,6 @@ chmod +x "$TESTS_DIR"/lib/*.sh 2>/dev/null || true
RESULTS_DIR=$(mktemp -d)
TEST_ENV_DIR=$(mktemp -d)

mkdir -p "$TEST_ENV_DIR/trust-config" "$TEST_ENV_DIR/trust-keystore"
export NONO_TRUST_TEST_USER_POLICY_PATH="$TEST_ENV_DIR/trust-config/trust-policy.json"
export NONO_TRUST_TEST_KEYSTORE_DIR="$TEST_ENV_DIR/trust-keystore"
export NONO_NO_UPDATE_CHECK=1
# Suppress the migration prompt (--profile <pack-name> when the pack
# isn't installed) and the "save denied paths as user profile?"
Expand All @@ -91,70 +88,9 @@ export NONO_NO_UPDATE_CHECK=1
export NONO_NO_MIGRATE=1
export NONO_NO_SAVE_PROMPT=1

# Audit is on by default, so every test invocation that does not pass
# --no-audit writes a session under $XDG_STATE_HOME/nono/audit/ (and $XDG_STATE_HOME/nono/rollbacks/
# for rollback tests), and appends to the audit ledger there. There is
# no env-var override for the audit root, so snapshot the pre-run state and
# restore it on exit. This removes only artefacts created during the run;
# pre-existing user sessions and ledger entries are preserved.
# Set NONO_TEST_KEEP_AUDIT=1 to skip cleanup for debugging.
NONO_AUDIT_ROOT="${XDG_STATE_HOME:-$HOME/.local/state}/nono/audit"
NONO_ROLLBACK_ROOT="${XDG_STATE_HOME:-$HOME/.local/state}/nono/rollbacks"
AUDIT_SNAPSHOT_DIR="$TEST_ENV_DIR/audit-snapshot"
mkdir -p "$AUDIT_SNAPSHOT_DIR"

snapshot_dirs_in() {
local root="$1"
local out="$2"
if [[ -d "$root" ]]; then
find "$root" -maxdepth 1 -mindepth 1 -type d -print > "$out" 2>/dev/null || :
else
: > "$out"
fi
}

snapshot_dirs_in "$NONO_AUDIT_ROOT" "$AUDIT_SNAPSHOT_DIR/audit.before"
snapshot_dirs_in "$NONO_ROLLBACK_ROOT" "$AUDIT_SNAPSHOT_DIR/rollback.before"

NONO_LEDGER_FILE="$NONO_AUDIT_ROOT/ledger.ndjson"
NONO_LEDGER_LOCK="$NONO_AUDIT_ROOT/ledger.lock"
NONO_LEDGER_BACKUP="$AUDIT_SNAPSHOT_DIR/ledger.ndjson"
NONO_LEDGER_EXISTED=0
NONO_LEDGER_LOCK_EXISTED=0
if [[ -f "$NONO_LEDGER_FILE" ]]; then
cp "$NONO_LEDGER_FILE" "$NONO_LEDGER_BACKUP"
NONO_LEDGER_EXISTED=1
fi
[[ -f "$NONO_LEDGER_LOCK" ]] && NONO_LEDGER_LOCK_EXISTED=1

cleanup_test_audit_artifacts() {
[[ "${NONO_TEST_KEEP_AUDIT:-0}" == "1" ]] && return 0

_remove_new_dirs() {
local root="$1"
local before="$2"
[[ -d "$root" ]] || return 0
while IFS= read -r -d '' dir; do
if ! grep -Fxq "$dir" "$before" 2>/dev/null; then
rm -rf "$dir"
fi
done < <(find "$root" -maxdepth 1 -mindepth 1 -type d -print0)
}

_remove_new_dirs "$NONO_AUDIT_ROOT" "$AUDIT_SNAPSHOT_DIR/audit.before"
_remove_new_dirs "$NONO_ROLLBACK_ROOT" "$AUDIT_SNAPSHOT_DIR/rollback.before"

if [[ "$NONO_LEDGER_EXISTED" -eq 1 ]]; then
cp "$NONO_LEDGER_BACKUP" "$NONO_LEDGER_FILE"
elif [[ -f "$NONO_LEDGER_FILE" ]]; then
rm -f "$NONO_LEDGER_FILE"
fi
if [[ "$NONO_LEDGER_LOCK_EXISTED" -eq 0 && -f "$NONO_LEDGER_LOCK" ]]; then
rm -f "$NONO_LEDGER_LOCK"
fi
}

trap 'cleanup_test_audit_artifacts; rm -rf "$RESULTS_DIR" "$TEST_ENV_DIR"' EXIT
# Each suite receives an isolated XDG_STATE_HOME below TEST_ENV_DIR, so audit
# sessions, rollback snapshots, and ledgers never touch the caller's state.
trap 'rm -rf "$RESULTS_DIR" "$TEST_ENV_DIR"' EXIT

# All suites to run (script:name pairs)
# Discover every shell suite so newly added suites cannot be omitted from CI.
Expand Down Expand Up @@ -194,19 +130,46 @@ echo ""
launch_suite() {
local script="$1"
local output_file="$2"

local exit_file="${output_file%.out}.exit"
local suite_id="${script%.sh}"
local suite_env_dir="$TEST_ENV_DIR/suites/$suite_id"

# Suites run in parallel and must not share mutable XDG state. In
# particular, rollback and audit suites otherwise race on the default
# rollback root and audit ledger. Keep HOME intact: sensitive-path suites
# intentionally exercise the caller's real protected paths.
mkdir -p \
"$suite_env_dir/config" \
"$suite_env_dir/state" \
"$suite_env_dir/cache" \
"$suite_env_dir/data" \
"$suite_env_dir/trust-config" \
"$suite_env_dir/trust-keystore"

if command -v timeout >/dev/null 2>&1; then
timeout "$SUITE_TIMEOUT" bash "$TESTS_DIR/integration/$script" > "$output_file" 2>&1
env \
XDG_CONFIG_HOME="$suite_env_dir/config" \
XDG_STATE_HOME="$suite_env_dir/state" \
XDG_CACHE_HOME="$suite_env_dir/cache" \
XDG_DATA_HOME="$suite_env_dir/data" \
NONO_TRUST_TEST_USER_POLICY_PATH="$suite_env_dir/trust-config/trust-policy.json" \
NONO_TRUST_TEST_KEYSTORE_DIR="$suite_env_dir/trust-keystore" \
timeout "$SUITE_TIMEOUT" bash "$TESTS_DIR/integration/$script" > "$output_file" 2>&1
rc=$?
if [[ "$rc" -eq 124 ]]; then
echo "" >> "$output_file"
echo "SUITE TIMED OUT after ${SUITE_TIMEOUT}s" >> "$output_file"
fi
echo "$rc" > "$exit_file"
else
bash "$TESTS_DIR/integration/$script" > "$output_file" 2>&1; rc=$?
env \
XDG_CONFIG_HOME="$suite_env_dir/config" \
XDG_STATE_HOME="$suite_env_dir/state" \
XDG_CACHE_HOME="$suite_env_dir/cache" \
XDG_DATA_HOME="$suite_env_dir/data" \
NONO_TRUST_TEST_USER_POLICY_PATH="$suite_env_dir/trust-config/trust-policy.json" \
NONO_TRUST_TEST_KEYSTORE_DIR="$suite_env_dir/trust-keystore" \
bash "$TESTS_DIR/integration/$script" > "$output_file" 2>&1; rc=$?
echo "$rc" > "$exit_file"
fi
}
Expand Down
Loading