Files
privatae-bastion/client-go/backupreseal_store_test.go
Privatae e4c9b69b46
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s
bastion 0.53.2
2026-10-20 04:42:14 +00:00

193 lines
6.6 KiB
Go

package main
// Gate: the pairing's request file is written by two processes (the daemon
// files, the person's `backup approve`/`deny` decides), and neither loses
// the other's write; an unreadable file is set aside, never wiped; and an
// answer frees the room its request took.
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
)
func junkSidecar(tag string, i int) string {
return "-----BEGIN AGE ENCRYPTED FILE-----\n" + tag + "-" + strconv.Itoa(i) + "\n-----END AGE ENCRYPTED FILE-----\n"
}
// TestResealHelperProcess is not a test on its own: the concurrency test
// below runs this binary again with these variables set, so each writer is a
// separate process, as the daemon and the CLI are.
func TestResealHelperProcess(t *testing.T) {
dir := os.Getenv("PRIVATAE_RESEAL_HELPER_DIR")
if dir == "" {
t.Skip("run by TestResealFileKeepsEveryWriteAcrossProcesses")
}
tag := os.Getenv("PRIVATAE_RESEAL_HELPER_TAG")
rounds, _ := strconv.Atoi(os.Getenv("PRIVATAE_RESEAL_HELPER_ROUNDS"))
to := mustRecipient(t)
for i := 0; i < rounds; i++ {
r, err := resealFile(dir, fixtureWho, resealReq{Sidecar: junkSidecar(tag, i), To: to, Stamp: fixtureStamp})
if err != nil {
t.Fatalf("%s: file %d: %v", tag, i, err)
}
if _, err := resealRefuse(dir, fixtureWho, r.ID, tag); err != nil {
t.Fatalf("%s: deny %d: %v", tag, i, err)
}
}
}
// Several processes each file a request and deny it, over and over, on one
// pairing's file. Every request filed is there at the end, and every one is
// refused: none was dropped by a stale save, and no answer was reverted to
// pending.
func TestResealFileKeepsEveryWriteAcrossProcesses(t *testing.T) {
if testing.Short() {
t.Skip("starts processes")
}
const procs, rounds = 4, 15 // 60 answered requests: under resealMaxAnswered, so none is dropped
if procs*rounds > resealMaxAnswered {
t.Fatal("the test would lose answered requests to the bound, not to a race")
}
dir := t.TempDir()
var wg sync.WaitGroup
errs := make([]error, procs)
outs := make([][]byte, procs)
for p := 0; p < procs; p++ {
wg.Add(1)
go func(p int) {
defer wg.Done()
cmd := exec.Command(os.Args[0], "-test.run=^TestResealHelperProcess$", "-test.count=1")
cmd.Env = append(os.Environ(), "PRIVATAE_RESEAL_HELPER_DIR="+dir,
fmt.Sprintf("PRIVATAE_RESEAL_HELPER_TAG=p%d", p), fmt.Sprintf("PRIVATAE_RESEAL_HELPER_ROUNDS=%d", rounds))
outs[p], errs[p] = cmd.CombinedOutput()
}(p)
}
wg.Wait()
for p := range errs {
if errs[p] != nil {
t.Fatalf("writer %d: %v\n%s", p, errs[p], outs[p])
}
}
m, err := resealRead(dir)
if err != nil {
t.Fatal(err)
}
if len(m) != procs*rounds {
t.Fatalf("%d of %d requests are on file: writes were lost", len(m), procs*rounds)
}
for _, r := range m {
if r.State != "refused" || !strings.HasPrefix(r.Error, "p") {
t.Fatalf("an answer was lost: %+v", r)
}
}
}
// A request file that does not parse is set aside whole, the error says
// where, and the next request starts a new file.
func TestResealUnreadableFileIsSetAsideNotWiped(t *testing.T) {
dir := t.TempDir()
junk := []byte(`{"br-1": {"id": "br-1", "state": "pending"` + "\x00 torn")
if err := os.WriteFile(resealPath(dir), junk, 0o600); err != nil {
t.Fatal(err)
}
to := mustRecipient(t)
_, err := resealFile(dir, fixtureWho, resealReq{Sidecar: junkSidecar("a", 0), To: to, Stamp: fixtureStamp})
if err == nil || !strings.Contains(err.Error(), "kept as") {
t.Fatalf("an unreadable request file was not reported: %v", err)
}
aside, _ := filepath.Glob(resealPath(dir) + resealAsideSuffix + "*")
if len(aside) != 1 {
t.Fatalf("the unreadable file was not set aside: %v", aside)
}
if got, _ := os.ReadFile(aside[0]); string(got) != string(junk) {
t.Fatal("the file set aside is not the file that was there")
}
if !strings.Contains(err.Error(), aside[0]) {
t.Errorf("the error does not say where the file is: %v", err)
}
r, err := resealFile(dir, fixtureWho, resealReq{Sidecar: junkSidecar("a", 0), To: to, Stamp: fixtureStamp})
if err != nil || r.State != "pending" {
t.Fatalf("a new file was not started: %v", err)
}
// "null" is an empty record, not a crash.
_ = os.WriteFile(resealPath(dir), []byte("null"), 0o600)
if _, err := resealFile(dir, fixtureWho, resealReq{Sidecar: junkSidecar("a", 1), To: to, Stamp: fixtureStamp}); err != nil {
t.Fatalf("a null record: %v", err)
}
}
// The bound is on requests waiting for an answer: answering one, either
// way, frees its room at once, and an answered request no longer holds the
// sidecar it came with.
func TestResealAnswerFreesRoom(t *testing.T) {
dir := t.TempDir()
to := mustRecipient(t)
var ids []string
for i := 0; i < resealMaxRequests; i++ {
r, err := resealFile(dir, fixtureWho, resealReq{Sidecar: junkSidecar("q", i), To: to, Stamp: fixtureStamp})
if err != nil {
t.Fatalf("request %d: %v", i, err)
}
ids = append(ids, r.ID)
}
next := resealReq{Sidecar: junkSidecar("q", 100), To: to, Stamp: fixtureStamp}
if _, err := resealFile(dir, fixtureWho, next); err == nil {
t.Fatal("a request past the bound was filed")
}
if _, err := resealRefuse(dir, fixtureWho, ids[0], ""); err != nil {
t.Fatal(err)
}
if _, err := resealFile(dir, fixtureWho, next); err != nil {
t.Fatalf("a deny did not free room: %v", err)
}
// An approval frees room too, whatever its outcome (this sidecar does not
// open, so it fails).
withBackupKeysDir(t)
newBackupFixture(t, "").installAsBastionKey(t)
if _, err := resealDecide(dir, fixtureWho, ids[1], true); err != nil {
t.Fatal(err)
}
if _, err := resealFile(dir, fixtureWho, resealReq{Sidecar: junkSidecar("q", 101), To: to, Stamp: fixtureStamp}); err != nil {
t.Fatalf("an approval did not free room: %v", err)
}
m, _ := resealRead(dir)
for _, id := range ids[:2] {
if m[id].State == "pending" || m[id].Sidecar != "" {
t.Fatalf("an answered request still holds its sidecar: %+v", m[id])
}
}
}
// Answered requests are kept for the host to read, at most resealMaxAnswered
// of them, the oldest answered going first.
func TestResealAnsweredRequestsAreBounded(t *testing.T) {
dir := t.TempDir()
to := mustRecipient(t)
var first string
for i := 0; i < resealMaxAnswered+5; i++ {
r, err := resealFile(dir, fixtureWho, resealReq{Sidecar: junkSidecar("z", i), To: to, Stamp: fixtureStamp})
if err != nil {
t.Fatal(err)
}
if i == 0 {
first = r.ID
}
if _, err := resealRefuse(dir, fixtureWho, r.ID, ""); err != nil {
t.Fatal(err)
}
}
m, _ := resealRead(dir)
if len(m) != resealMaxAnswered {
t.Fatalf("%d answered requests kept, want %d", len(m), resealMaxAnswered)
}
if _, ok := m[first]; ok {
t.Fatal("the oldest answered request was kept and a newer one dropped")
}
}