check / check (push) Failing after 3s
internal/cli drops its copies of vault.ErrSecretNotFound, ErrVaultNotFound, ErrVersionNotFound and ErrSecretExists and of the secret package's keychain and Secure Enclave errors, and its second error for an unknown unlocker type, an invalid mnemonic, a length below 1, an unsupported secret type and an oversized secret. vault.ErrNilValueBuffer becomes secret.ErrNilValueBuffer. Every error of secret.ReadPassphrase wraps ErrPassphraseNotRead. ResolveGPGKeyFingerprint returns ErrGPGKeyNotFound for a key the keyring lacks. storeInKeychain returns errNilDataBuffer. bip85's ErrPasswordTooShort and ErrEncodedTooShort go with their unreachable checks, as does the macOS check in macOS-only code. Tests that matched these errors' text use errors.Is. Model: opus-5-5
439 lines
11 KiB
Go
439 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), nil)
|
|
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: wantErr, or the secret stored when wantErr is nil.
|
|
func runAddSecretSizeCase(t *testing.T, size int, wantErr error) {
|
|
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 != nil {
|
|
require.ErrorIs(t, err, wantErr)
|
|
|
|
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: wantErr, or the secret stored when wantErr is nil.
|
|
func runImportSecretSizeCase(t *testing.T, size int, wantErr error) {
|
|
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 != nil {
|
|
require.ErrorIs(t, err, wantErr)
|
|
|
|
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
|
|
wantErr error
|
|
}{
|
|
{
|
|
name: "1KB secret",
|
|
size: 1024,
|
|
},
|
|
{
|
|
name: "10KB secret",
|
|
size: 10 * 1024,
|
|
},
|
|
{
|
|
name: "100KB secret",
|
|
size: 100 * 1024,
|
|
},
|
|
{
|
|
name: "1MB secret",
|
|
size: 1024 * 1024,
|
|
},
|
|
{
|
|
name: "10MB secret",
|
|
size: 10 * 1024 * 1024,
|
|
},
|
|
{
|
|
name: "99MB secret",
|
|
size: 99 * 1024 * 1024,
|
|
},
|
|
{
|
|
name: "100MB secret minus 1 byte",
|
|
size: 100*1024*1024 - 1,
|
|
},
|
|
{
|
|
name: "101MB secret - should fail",
|
|
size: 101 * 1024 * 1024,
|
|
wantErr: errSecretTooLarge,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
runAddSecretSizeCase(t, tt.size, tt.wantErr)
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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
|
|
wantErr error
|
|
}{
|
|
{
|
|
name: "1KB file",
|
|
size: 1024,
|
|
},
|
|
{
|
|
name: "10KB file",
|
|
size: 10 * 1024,
|
|
},
|
|
{
|
|
name: "100KB file",
|
|
size: 100 * 1024,
|
|
},
|
|
{
|
|
name: "1MB file",
|
|
size: 1024 * 1024,
|
|
},
|
|
{
|
|
name: "10MB file",
|
|
size: 10 * 1024 * 1024,
|
|
},
|
|
{
|
|
name: "99MB file",
|
|
size: 99 * 1024 * 1024,
|
|
},
|
|
{
|
|
name: "100MB file",
|
|
size: 100 * 1024 * 1024,
|
|
},
|
|
{
|
|
name: "101MB file - should fail",
|
|
size: 101 * 1024 * 1024,
|
|
wantErr: errSecretTooLarge,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
runImportSecretSizeCase(t, tt.size, tt.wantErr)
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|