check / check (push) Failing after 2s
init and vault create put the mnemonic into the process environment for vault.CreateVault to read back, so every program they ran, gpg included, inherited it, and SB_SECRET_MNEMONIC and SB_UNLOCK_PASSPHRASE were read at 13 places and never unset. Each command that may need them now reads both once, in its RunE, into locked buffers on the CLI Instance, and unsets them at once. The buffers are passed down: vault.CreateVault takes the mnemonic, a Vault carries Mnemonic and UnlockPassphrase, and the PGP, keychain and Secure Enclave unlocker constructors take both; CreatePGPUnlocker sets them on the vault it loads through SetMnemonic and SetUnlockPassphrase, new in VaultInterface. README warns against both variables. Model: opus-5-5
458 lines
11 KiB
Go
458 lines
11 KiB
Go
//nolint:testpackage // white-box test of unexported internals
|
|
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/rand"
|
|
"fmt"
|
|
"io"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.eeqj.de/sneak/secret/internal/vault"
|
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
|
"github.com/spf13/afero"
|
|
"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.
|
|
//
|
|
//nolint:ireturn // afero.Fs is the filesystem abstraction used throughout
|
|
func newSizeTestVault(t *testing.T) (afero.Fs, *vault.Vault) {
|
|
t.Helper()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
|
|
// Create vault
|
|
_, err := vault.CreateVault(fs, testStateDir, testVaultName, testMnemonicBuffer(t))
|
|
require.NoError(t, err)
|
|
|
|
// Set current vault
|
|
currentVaultPath := filepath.Join(testStateDir, "currentvault")
|
|
vaultPath := filepath.Join(testStateDir, "vaults.d", testVaultName)
|
|
err = afero.WriteFile(fs, currentVaultPath, []byte(vaultPath), 0o600)
|
|
require.NoError(t, err)
|
|
|
|
// Get vault and set up long-term key
|
|
vlt, err := vault.GetCurrentVault(fs, testStateDir)
|
|
require.NoError(t, err)
|
|
|
|
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
|
require.NoError(t, err)
|
|
vlt.Unlock(ltIdentity)
|
|
|
|
return fs, vlt
|
|
}
|
|
|
|
// runAddSecretSizeCase adds a secret of the given size through stdin and
|
|
// verifies the outcome.
|
|
func runAddSecretSizeCase(t *testing.T, size int, wantErr bool, errMsg string) {
|
|
t.Helper()
|
|
skipIfLockedMemoryTooLow(t, size)
|
|
|
|
fs, vlt := newSizeTestVault(t)
|
|
|
|
// Generate test data of specified size
|
|
testData := make([]byte, size)
|
|
_, err := rand.Read(testData)
|
|
require.NoError(t, err)
|
|
|
|
// Add newline that will be stripped
|
|
testDataWithNewline := make([]byte, 0, len(testData)+1)
|
|
testDataWithNewline = append(testDataWithNewline, testData...)
|
|
testDataWithNewline = append(testDataWithNewline, '\n')
|
|
|
|
// Create command with fake stdin
|
|
cmd := &cobra.Command{}
|
|
cmd.SetIn(bytes.NewReader(testDataWithNewline))
|
|
|
|
// Create CLI instance
|
|
cli, err := NewCLIInstance()
|
|
if err != nil {
|
|
t.Fatalf("failed to initialize CLI: %v", err)
|
|
}
|
|
|
|
cli.fs = fs
|
|
cli.stateDir = testStateDir
|
|
cli.cmd = cmd
|
|
|
|
// Test adding the secret
|
|
secretName := fmt.Sprintf("test-secret-%d", size)
|
|
err = cli.AddSecret(secretName, false)
|
|
|
|
if wantErr {
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), errMsg)
|
|
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
|
|
// Verify the secret was stored correctly
|
|
retrievedValue, err := vlt.GetSecret(secretName)
|
|
require.NoError(t, err)
|
|
|
|
defer retrievedValue.Destroy()
|
|
|
|
assert.Equal(t, testData, retrievedValue.Bytes(),
|
|
"Retrieved secret should match original (without newline)")
|
|
}
|
|
|
|
// runImportSecretSizeCase imports a secret file of the given size and
|
|
// verifies the outcome.
|
|
func runImportSecretSizeCase(t *testing.T, size int, wantErr bool, errMsg string) {
|
|
t.Helper()
|
|
skipIfLockedMemoryTooLow(t, size)
|
|
|
|
fs, vlt := newSizeTestVault(t)
|
|
|
|
// Generate test data of specified size
|
|
testData := make([]byte, size)
|
|
_, err := rand.Read(testData)
|
|
require.NoError(t, err)
|
|
|
|
// Write test data to file
|
|
testFile := fmt.Sprintf("/test/secret-%d.bin", size)
|
|
err = afero.WriteFile(fs, testFile, testData, 0o600)
|
|
require.NoError(t, err)
|
|
|
|
// Create command
|
|
cmd := &cobra.Command{}
|
|
|
|
// Create CLI instance
|
|
cli, err := NewCLIInstance()
|
|
if err != nil {
|
|
t.Fatalf("failed to initialize CLI: %v", err)
|
|
}
|
|
|
|
cli.fs = fs
|
|
cli.stateDir = testStateDir
|
|
|
|
// Test importing the secret
|
|
secretName := fmt.Sprintf("imported-secret-%d", size)
|
|
err = cli.ImportSecret(cmd, secretName, testFile, false)
|
|
|
|
if wantErr {
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), errMsg)
|
|
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
|
|
// Verify the secret was stored correctly
|
|
retrievedValue, err := vlt.GetSecret(secretName)
|
|
require.NoError(t, err)
|
|
|
|
defer retrievedValue.Destroy()
|
|
|
|
assert.Equal(t, testData, retrievedValue.Bytes(),
|
|
"Retrieved secret should match original")
|
|
}
|
|
|
|
// TestAddSecretVariousSizes tests adding secrets of various sizes through stdin
|
|
//
|
|
//nolint:paralleltest // together the subtests lock more than the memlock limit
|
|
func TestAddSecretVariousSizes(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
size int
|
|
shouldError bool
|
|
errorMsg string
|
|
}{
|
|
{
|
|
name: "1KB secret",
|
|
size: 1024,
|
|
shouldError: false,
|
|
},
|
|
{
|
|
name: "10KB secret",
|
|
size: 10 * 1024,
|
|
shouldError: false,
|
|
},
|
|
{
|
|
name: "100KB secret",
|
|
size: 100 * 1024,
|
|
shouldError: false,
|
|
},
|
|
{
|
|
name: "1MB secret",
|
|
size: 1024 * 1024,
|
|
shouldError: false,
|
|
},
|
|
{
|
|
name: "10MB secret",
|
|
size: 10 * 1024 * 1024,
|
|
shouldError: false,
|
|
},
|
|
{
|
|
name: "99MB secret",
|
|
size: 99 * 1024 * 1024,
|
|
shouldError: false,
|
|
},
|
|
{
|
|
name: "100MB secret minus 1 byte",
|
|
size: 100*1024*1024 - 1,
|
|
shouldError: false,
|
|
},
|
|
{
|
|
name: "101MB secret - should fail",
|
|
size: 101 * 1024 * 1024,
|
|
shouldError: true,
|
|
errorMsg: "secret too large: exceeds 100MB limit",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
runAddSecretSizeCase(t, tt.size, tt.shouldError, tt.errorMsg)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestImportSecretVariousSizes tests importing secrets of various sizes from files
|
|
//
|
|
//nolint:paralleltest // together the subtests lock more than the memlock limit
|
|
func TestImportSecretVariousSizes(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
size int
|
|
shouldError bool
|
|
errorMsg string
|
|
}{
|
|
{
|
|
name: "1KB file",
|
|
size: 1024,
|
|
shouldError: false,
|
|
},
|
|
{
|
|
name: "10KB file",
|
|
size: 10 * 1024,
|
|
shouldError: false,
|
|
},
|
|
{
|
|
name: "100KB file",
|
|
size: 100 * 1024,
|
|
shouldError: false,
|
|
},
|
|
{
|
|
name: "1MB file",
|
|
size: 1024 * 1024,
|
|
shouldError: false,
|
|
},
|
|
{
|
|
name: "10MB file",
|
|
size: 10 * 1024 * 1024,
|
|
shouldError: false,
|
|
},
|
|
{
|
|
name: "99MB file",
|
|
size: 99 * 1024 * 1024,
|
|
shouldError: false,
|
|
},
|
|
{
|
|
name: "100MB file",
|
|
size: 100 * 1024 * 1024,
|
|
shouldError: false,
|
|
},
|
|
{
|
|
name: "101MB file - should fail",
|
|
size: 101 * 1024 * 1024,
|
|
shouldError: true,
|
|
errorMsg: "secret file too large: exceeds 100MB limit",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
runImportSecretSizeCase(t, tt.size, tt.shouldError, tt.errorMsg)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestAddSecretBufferGrowth tests that our buffer growth strategy works correctly
|
|
//
|
|
//nolint:paralleltest // together the subtests lock more than the memlock limit
|
|
func TestAddSecretBufferGrowth(t *testing.T) {
|
|
// Test various sizes that should trigger buffer growth
|
|
sizes := []int{
|
|
1, // Single byte
|
|
100, // Small
|
|
4095, // Just under initial 4KB
|
|
4096, // Exactly 4KB
|
|
4097, // Just over 4KB
|
|
8191, // Just under 8KB (first double)
|
|
8192, // Exactly 8KB
|
|
8193, // Just over 8KB
|
|
12288, // 12KB (should trigger second double)
|
|
16384, // 16KB
|
|
32768, // 32KB (after more doublings)
|
|
65536, // 64KB
|
|
131072, // 128KB
|
|
524288, // 512KB
|
|
1048576, // 1MB
|
|
2097152, // 2MB
|
|
}
|
|
|
|
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
|
|
// Use a pattern that's easy to verify
|
|
testData := make([]byte, size)
|
|
for i := range testData {
|
|
testData[i] = byte(i % 256)
|
|
}
|
|
|
|
// Create command with fake stdin (no newline)
|
|
cmd := &cobra.Command{}
|
|
cmd.SetIn(bytes.NewReader(testData))
|
|
|
|
// Create CLI instance
|
|
cli, err := NewCLIInstance()
|
|
if err != nil {
|
|
t.Fatalf("failed to initialize CLI: %v", err)
|
|
}
|
|
|
|
cli.fs = fs
|
|
cli.stateDir = testStateDir
|
|
cli.cmd = cmd
|
|
|
|
// Test adding the secret
|
|
secretName := fmt.Sprintf("buffer-test-%d", size)
|
|
err = cli.AddSecret(secretName, false)
|
|
require.NoError(t, err)
|
|
|
|
// Verify the secret was stored correctly
|
|
retrievedValue, err := vlt.GetSecret(secretName)
|
|
require.NoError(t, err)
|
|
|
|
defer retrievedValue.Destroy()
|
|
|
|
assert.Equal(t, testData, retrievedValue.Bytes(),
|
|
"Retrieved secret should match original exactly")
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestAddSecretStreamingBehavior tests that we handle streaming input correctly
|
|
func TestAddSecretStreamingBehavior(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs, vlt := newSizeTestVault(t)
|
|
|
|
// Create a custom reader that simulates slow streaming input
|
|
// This will help verify our buffer handling works correctly with partial reads
|
|
testData := []byte(strings.Repeat("Hello, World! ", 1000)) // ~14KB
|
|
streamingStdin := &slowReader{
|
|
data: testData,
|
|
chunkSize: 1000, // Read 1KB at a time
|
|
}
|
|
|
|
// Create command with slow reader as stdin
|
|
cmd := &cobra.Command{}
|
|
cmd.SetIn(streamingStdin)
|
|
|
|
// Create CLI instance
|
|
cli, err := NewCLIInstance()
|
|
if err != nil {
|
|
t.Fatalf("failed to initialize CLI: %v", err)
|
|
}
|
|
|
|
cli.fs = fs
|
|
cli.stateDir = testStateDir
|
|
cli.cmd = cmd
|
|
|
|
// Test adding the secret
|
|
err = cli.AddSecret("streaming-test", false)
|
|
require.NoError(t, err)
|
|
|
|
// Verify the secret was stored correctly
|
|
retrievedValue, err := vlt.GetSecret("streaming-test")
|
|
require.NoError(t, err)
|
|
|
|
defer retrievedValue.Destroy()
|
|
|
|
assert.Equal(t, testData, retrievedValue.Bytes(),
|
|
"Retrieved secret should match original")
|
|
}
|
|
|
|
// slowReader simulates a reader that returns data in small chunks
|
|
type slowReader struct {
|
|
data []byte
|
|
offset int
|
|
chunkSize int
|
|
}
|
|
|
|
func (r *slowReader) Read(p []byte) (int, error) {
|
|
if r.offset >= len(r.data) {
|
|
return 0, io.EOF
|
|
}
|
|
|
|
// Read at most chunkSize bytes, bounded by the remaining data and
|
|
// the destination buffer
|
|
remaining := len(r.data) - r.offset
|
|
toRead := min(r.chunkSize, remaining, len(p))
|
|
|
|
n := copy(p, r.data[r.offset:r.offset+toRead])
|
|
r.offset += n
|
|
|
|
if r.offset >= len(r.data) {
|
|
return n, io.EOF
|
|
}
|
|
|
|
return n, nil
|
|
}
|