Author SHA1 Message Date
sneak 6f2538cfd2 Wipe memguard buffers on every exit, restore echo on Ctrl-C (closes #35)
check / check (push) Successful in 1m4s
Entry() now returns the exit code and only main calls os.Exit, so the
deferred memguard.Purge() in Entry() runs on success and on error;
before, os.Exit(1) skipped every deferred Destroy().

SIGINT and SIGTERM go through memguard's handler, which wipes every
buffer and exits with status 1. The passphrase prompt turns terminal
echo off until its read returns, and the handler exits before that, so
on Ctrl-C the handler first restores the terminal settings saved at
startup. It leaves the terminal alone on SIGTERM, which can reach a
background process, and changing the terminal from the background would
stop the process.

Model: opus-5-5
2026-10-03 12:15:47 +00:00
clawbot d52b4f1240 Let a plain docker build pass and stamp the git version (closes #57)
check / check (push) Successful in 1m1s
The size tests skip a case whose secret needs more locked memory than
the process can lock, found by locking a buffer of that size: memguard
panics otherwise, and a plain `docker build .` runs under an 8 MiB
RLIMIT_MEMLOCK. script/cibuild, or any process allowed to lock past the
limit, runs every case.

The build stage stamps the VERSION build argument, else
`git describe --tags --always`, and fails when .git is present but
yields no version. `make build` stamps `git describe` too instead of
the fixed 0.1.0. .dockerignore keeps .git/config out; script/docker is
now the canonical copy.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
2026-10-02 14:16:02 +02:00
11 changed files with 233 additions and 14 deletions
+6
View File
@@ -1,3 +1,9 @@
# .git is sent without its config. Without a VERSION build argument the
# stage that compiles runs `git describe --tags --always` on .git, which
# does not need .git/config; that file can hold a credential, such as a
# password in a remote URL or the token the CI checkout step stores there.
.git/config
# Build artifacts
secret
coverage.out
+14 -1
View File
@@ -27,7 +27,20 @@ RUN go mod download
COPY . .
RUN make test
RUN make build
# The version stamped into the binary: the VERSION build argument when one
# is given, otherwise `git describe --tags --always` of the .git the build
# context carries: the tag on a tagged commit, tag-N-gHASH on a commit after
# one, the short commit when no tag is reachable. A context that carries .git
# and still yields no version fails the build.
ARG VERSION
RUN version="${VERSION:-$(git describe --tags --always)}"; \
if [ -e .git ] && { [ -z "$version" ] || [ "$version" = dev ] || \
[ "$version" = unknown ]; }; then \
echo "no version could be derived although the build context carries .git" >&2; \
exit 1; \
fi; \
make build VERSION="${version:-dev}"
# Runtime stage
# alpine 3.23 (2026-03-10)
+1 -1
View File
@@ -2,7 +2,7 @@ export CGO_ENABLED=1
export DOCKER_HOST := ssh://root@ber1app1.local
# Version information
VERSION := 0.1.0
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
GIT_COMMIT := $(shell git rev-parse HEAD 2>/dev/null || echo "unknown")
LDFLAGS := -X 'git.eeqj.de/sneak/secret/internal/cli.Version=$(VERSION)' \
-X 'git.eeqj.de/sneak/secret/internal/cli.GitCommit=$(GIT_COMMIT)'
+14
View File
@@ -25,6 +25,20 @@ Bring the repo into policy compliance in one commit:
# Completed Steps
- 2026-10-03: Key material is wiped on every exit: `Entry()` returns
the exit code after its deferred `memguard.Purge()` has run, and only
`main` calls `os.Exit`. SIGINT and SIGTERM go through memguard's
handler, which wipes every buffer before exiting; on Ctrl-C it first
restores the terminal settings from startup, so an interrupted
passphrase prompt no longer leaves echo off.
- 2026-10-02: A plain `docker build .` builds again: the size tests
skip a case that needs more locked memory than the process can
lock, and run every case under `script/cibuild`. The image stamps the
`VERSION` build argument, else `git describe --tags --always`, into
`Version`, and fails if `.git` is present but yields no version;
`make build` stamps `git describe` too, not a fixed `0.1.0`.
`.dockerignore` keeps `.git/config` out; `script/docker` is the
canonical copy.
- 2026-08-07: Updated golangci-lint to v2.12.2 with the canonical
`.golangci.yml` (all linters enabled minus the standard disable
list, `lll` 88, tests linted); bumped the `Dockerfile` lint-stage
+6 -2
View File
@@ -1,8 +1,12 @@
// Package main is the entry point for the secret CLI application.
package main
import "git.eeqj.de/sneak/secret/internal/cli"
import (
"os"
"git.eeqj.de/sneak/secret/internal/cli"
)
func main() {
cli.Entry()
os.Exit(cli.Entry())
}
+1 -1
View File
@@ -16,6 +16,7 @@ require (
github.com/stretchr/testify v1.8.4
github.com/tyler-smith/go-bip39 v1.1.0
golang.org/x/crypto v0.38.0
golang.org/x/sys v0.33.0
golang.org/x/term v0.32.0
)
@@ -31,7 +32,6 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+108
View File
@@ -0,0 +1,108 @@
package cli_test
import (
"bufio"
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"git.eeqj.de/sneak/secret/internal/cli"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/awnumar/memguard"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Entry must return its exit code rather than exit, so that its deferred
// memguard purge runs on the success and the error path alike.
//
//nolint:paralleltest // sets os.Args, and Entry wipes every buffer in the process
func TestEntryWipesBuffersAndReturnsExitCode(t *testing.T) {
savedArgs := os.Args
t.Cleanup(func() { os.Args = savedArgs })
tests := []struct {
args []string
exitCode int
}{
{args: []string{"secret", "--help"}, exitCode: 0},
{args: []string{"secret", "no-such-command"}, exitCode: 1},
}
for _, tt := range tests {
buf := memguard.NewBufferFromBytes([]byte("key material"))
os.Args = tt.args
assert.Equal(t, tt.exitCode, cli.Entry(), "exit code for %v", tt.args)
assert.False(t, buf.IsAlive(), "Entry left a buffer unwiped for %v", tt.args)
}
}
// Ctrl-C while `secret add` waits for the value on stdin must end the
// process through memguard's signal handler, which wipes every buffer and
// exits with status 1, not through Go's default handling, which kills the
// process with the buffers intact.
func TestInterruptExitsThroughMemguard(t *testing.T) {
t.Parallel()
const waitingForValue = "Reading secret value from stdin"
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
defer cancel()
wd, err := filepath.Abs("../..")
require.NoError(t, err)
secretPath := filepath.Join(wd, "secret")
env := []string{
secret.EnvStateDir + "=" + t.TempDir(),
secret.EnvMnemonic + "=" + testMnemonic,
secret.EnvUnlockPassphrase + "=test-passphrase",
"PATH=/usr/bin:/bin",
// The debug log on stderr shows when add starts waiting for the value.
"GODEBUG=berlin.sneak.pkg.secret",
}
//nolint:gosec // G204: test executes the freshly built secret binary
initCmd := exec.CommandContext(ctx, secretPath, "init")
initCmd.Env = env
output, err := initCmd.CombinedOutput()
require.NoError(t, err, "init should succeed: %s", output)
//nolint:gosec // G204: test executes the freshly built secret binary
addCmd := exec.CommandContext(ctx, secretPath, "add", "test/secret")
addCmd.Env = env
// Held open and never written, so add keeps waiting for the value.
stdin, err := addCmd.StdinPipe()
require.NoError(t, err)
defer func() { _ = stdin.Close() }()
stderr, err := addCmd.StderrPipe()
require.NoError(t, err)
require.NoError(t, addCmd.Start())
waiting := false
scanner := bufio.NewScanner(stderr)
for !waiting && scanner.Scan() {
waiting = strings.Contains(scanner.Text(), waitingForValue)
}
require.True(t, waiting, "add never logged %q", waitingForValue)
require.NoError(t, addCmd.Process.Signal(os.Interrupt))
err = addCmd.Wait()
var exitErr *exec.ExitError
require.ErrorAs(t, err, &exitErr)
assert.Equal(t, 1, exitErr.ExitCode(), "add ended with %v", err)
}
+25 -5
View File
@@ -2,19 +2,39 @@ package cli
import (
"os"
"syscall"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/awnumar/memguard"
"github.com/spf13/cobra"
"golang.org/x/term"
)
// Entry is the entry point for the secret CLI application
func Entry() {
cmd := newRootCmd()
// Entry runs the secret CLI and returns the process exit code. It wipes
// every memguard buffer before it returns, so the caller must do nothing
// but exit with the code.
func Entry() int {
// On SIGINT or SIGTERM memguard runs this function, wipes every buffer
// and exits with status 1. The passphrase prompt turns terminal echo
// off until the read finishes, so Ctrl-C there would leave echo off.
// Ctrl-C means this process is in the terminal's foreground and may
// reset it; doing that from the background would stop the process.
terminalState, terminalErr := term.GetState(syscall.Stdin)
err := cmd.Execute()
memguard.CatchSignal(func(sig os.Signal) {
if sig == os.Interrupt && terminalErr == nil {
_ = term.Restore(syscall.Stdin, terminalState)
}
}, os.Interrupt, syscall.SIGTERM)
defer memguard.Purge()
err := newRootCmd().Execute()
if err != nil {
os.Exit(1)
return 1
}
return 0
}
func newRootCmd() *cobra.Command {
+44
View File
@@ -17,11 +17,51 @@ import (
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sys/unix"
)
// testVaultName is the vault name used by the size tests.
const testVaultName = "test-vault"
// lockedBytesPerSecretByte bounds the locked memory that storing a secret
// holds at once: the buffers it is read into reach up to 1.5 times its
// size, and they are then copied into one more buffer of its size.
const lockedBytesPerSecretByte = 3
// skipIfLockedMemoryTooLow skips the test when this process cannot lock
// the memory a secret of size bytes needs, found by locking a buffer of
// that size and releasing it. memguard panics, ending the whole test run,
// when it cannot lock a buffer, and a plain `docker build .` runs the
// tests under an 8 MiB locked-memory limit (RLIMIT_MEMLOCK). A process
// allowed to lock past that limit runs every case.
func skipIfLockedMemoryTooLow(t *testing.T, size int) {
t.Helper()
need := lockedBytesPerSecretByte * size
buf, err := unix.Mmap(-1, 0, need,
unix.PROT_READ|unix.PROT_WRITE, unix.MAP_PRIVATE|unix.MAP_ANON)
require.NoError(t, err)
lockErr := unix.Mlock(buf)
// Unmapping the buffer also unlocks it.
err = unix.Munmap(buf)
require.NoError(t, err)
if lockErr != nil {
var limit unix.Rlimit
err = unix.Getrlimit(unix.RLIMIT_MEMLOCK, &limit)
require.NoError(t, err)
t.Skipf("a %d-byte secret needs up to %d bytes of locked memory, "+
"which could not be locked under the locked-memory limit "+
"(RLIMIT_MEMLOCK) of %d bytes: %v",
size, need, limit.Cur, lockErr)
}
}
// newSizeTestVault creates an in-memory vault unlocked with the test
// mnemonic and returns the filesystem and vault.
//
@@ -59,6 +99,7 @@ func newSizeTestVault(t *testing.T) (afero.Fs, *vault.Vault) {
// verifies the outcome.
func runAddSecretSizeCase(t *testing.T, size int, wantErr bool, errMsg string) {
t.Helper()
skipIfLockedMemoryTooLow(t, size)
fs, vlt := newSizeTestVault(t)
@@ -110,6 +151,7 @@ func runAddSecretSizeCase(t *testing.T, size int, wantErr bool, errMsg string) {
// verifies the outcome.
func runImportSecretSizeCase(t *testing.T, size int, wantErr bool, errMsg string) {
t.Helper()
skipIfLockedMemoryTooLow(t, size)
fs, vlt := newSizeTestVault(t)
@@ -300,6 +342,8 @@ func TestAddSecretBufferGrowth(t *testing.T) {
for _, size := range sizes {
t.Run(fmt.Sprintf("size_%d", size), func(t *testing.T) {
skipIfLockedMemoryTooLow(t, size)
fs, vlt := newSizeTestVault(t)
// Create test data of exactly the specified size
+3 -2
View File
@@ -1,8 +1,9 @@
#!/bin/sh
# script/cibuild: run the CI build. The Dockerfile runs script/check
# (via make check), so a successful build implies all checks pass.
# The Gitea workflow runs this on push. The memlock ulimit is required
# because the test suite uses memguard, which mlocks memory.
# The Gitea workflow runs this on push. The memlock ulimit lets the tests
# that lock large secrets in memory (memguard mlocks them) run; under the
# lower limit of a plain `docker build .` they are skipped.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
+11 -2
View File
@@ -1,7 +1,8 @@
#!/bin/sh
# script/docker: build the Docker image tagged with the project name.
# Identical in all repos; the tag comes from script/projectname.
# Generic: needs no adaptation.
# --no-cache because the gate phases the final stage depends on are RUN
# steps, and a cached one is a check that did not run.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -9,7 +10,15 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
docker build -t "$("$SCRIPT_DIR/projectname")" .
# Own line: a failing command substitution inside an argument does
# not trip `set -e`, so the inline form degrades silently to an
# empty constant. The VERSION build argument takes precedence over
# the version a build stage derives from the .git in the context.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build --no-cache \
--build-arg VERSION="$version" \
-t "$("$SCRIPT_DIR/projectname")" .
}
main "$@"