bastion 0.53.2
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s

This commit is contained in:
Privatae
2026-10-20 04:42:14 +00:00
commit e4c9b69b46
471 changed files with 159794 additions and 0 deletions
+331
View File
@@ -0,0 +1,331 @@
package main
// Gate: backup_push sends exactly the named backup file, from this pairing's
// backup folder or a folder the person shared with it, to a signed upload
// URL, and nothing else anywhere else. Start answers at once; the digest is
// checked in the job, before anything is sent.
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
)
const fixtureData = fixtureStamp + ".tar.zst.age"
func pushFixture(t *testing.T, root string, body []byte) (string, string) {
t.Helper()
if err := os.WriteFile(filepath.Join(root, fixtureData), body, 0o600); err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(body)
return "./" + fixtureData, hex.EncodeToString(sum[:])
}
func uploadServer(t *testing.T, status int) (*httptest.Server, *[]byte, *string) {
t.Helper()
var got []byte
var method string
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
method = r.Method
got, _ = io.ReadAll(r.Body)
w.WriteHeader(status)
}))
t.Cleanup(srv.Close)
old := http.DefaultTransport
http.DefaultTransport = srv.Client().Transport
t.Cleanup(func() { http.DefaultTransport = old })
return srv, &got, &method
}
func pushJob(root, sum string) *backupPushJob {
return &backupPushJob{ID: "p", SHA256: sum, State: "pending", root: root}
}
func TestBackupPushSendsExactlyTheNamedFile(t *testing.T) {
allowLoopbackFetch(t)
root := t.TempDir()
body := []byte(strings.Repeat("ciphertext of a backup ", 4000))
_, sum := pushFixture(t, root, body)
srv, got, method := uploadServer(t, 200)
job := pushJob(root, sum)
runBackupPush(job, srv.URL+"/signed-put", filepath.Join(root, fixtureData))
if job.State != "done" {
t.Fatalf("state=%s error=%s", job.State, job.Error)
}
if *method != http.MethodPut || string(*got) != string(body) {
t.Fatalf("the destination received %s of %d bytes, not the file", *method, len(*got))
}
}
func TestBackupPushFailsWhenTheDestinationRefuses(t *testing.T) {
allowLoopbackFetch(t)
root := t.TempDir()
_, sum := pushFixture(t, root, []byte("a backup"))
srv, _, _ := uploadServer(t, 403)
job := pushJob(root, sum)
runBackupPush(job, srv.URL+"/expired", filepath.Join(root, fixtureData))
if job.State != "failed" || !strings.Contains(job.Error, "403") {
t.Fatalf("a refused upload reads %s %q", job.State, job.Error)
}
}
// The digest is checked in the job before anything is sent.
func TestBackupPushSendsNothingWhenTheFileIsNotTheOneNamed(t *testing.T) {
allowLoopbackFetch(t)
root := t.TempDir()
_, sum := pushFixture(t, root, []byte("the backup that was named"))
_ = os.WriteFile(filepath.Join(root, fixtureData), []byte("the backup that is there"), 0o600)
srv, got, method := uploadServer(t, 200)
job := pushJob(root, sum)
runBackupPush(job, srv.URL+"/put", filepath.Join(root, fixtureData))
if job.State != "failed" || !strings.Contains(job.Error, "sha256 differs") {
t.Fatalf("a file that is not the named one: %s %q", job.State, job.Error)
}
if *method != "" || len(*got) != 0 {
t.Fatal("bytes were sent before the digest was checked")
}
}
// Bytes changed while they are being sent are caught by the hash taken as
// they go: the job fails rather than reporting the named backup sent.
func TestBackupPushFailsWhenTheFileChangesWhileSending(t *testing.T) {
allowLoopbackFetch(t)
root := t.TempDir()
body := make([]byte, 32<<20)
_, sum := pushFixture(t, root, body)
path := filepath.Join(root, fixtureData)
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
buf := make([]byte, 64<<10)
_, _ = io.ReadFull(r.Body, buf)
// The tail of the file changes in place while the upload is under way.
if f, err := os.OpenFile(path, os.O_WRONLY, 0); err == nil {
_, _ = f.WriteAt([]byte(strings.Repeat("X", 4096)), int64(len(body)-4096))
_ = f.Close()
}
_, _ = io.Copy(io.Discard, r.Body)
w.WriteHeader(200)
}))
t.Cleanup(srv.Close)
old := http.DefaultTransport
http.DefaultTransport = srv.Client().Transport
t.Cleanup(func() { http.DefaultTransport = old })
job := pushJob(root, sum)
runBackupPush(job, srv.URL+"/put", path)
if job.State != "failed" || !strings.Contains(job.Error, "changed while it was being sent") {
t.Fatalf("a file changed mid-send reads %s %q", job.State, job.Error)
}
}
func TestBackupPushNeverFollowsARedirect(t *testing.T) {
allowLoopbackFetch(t)
root := t.TempDir()
_, sum := pushFixture(t, root, []byte("a backup"))
var second bool
elsewhere := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
second = true
}))
t.Cleanup(elsewhere.Close)
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 303 is the redirect an HTTP client follows even for an upload
// (as a GET); a client that follows redirects at all would go.
http.Redirect(w, r, elsewhere.URL+"/steal", http.StatusSeeOther)
}))
t.Cleanup(srv.Close)
old := http.DefaultTransport
http.DefaultTransport = srv.Client().Transport
t.Cleanup(func() { http.DefaultTransport = old })
job := pushJob(root, sum)
runBackupPush(job, srv.URL+"/put", filepath.Join(root, fixtureData))
if second {
t.Fatal("the backup followed a redirect to a place the host did not name")
}
if job.State != "failed" {
t.Fatalf("a redirect was reported as sent: %s", job.State)
}
}
// A job's error never quotes the signed URL's query.
func TestBackupPushErrorsCarryNoSignature(t *testing.T) {
root := t.TempDir()
_, sum := pushFixture(t, root, []byte("a backup"))
// The dial wall is left up: 127.0.0.1 is refused at connect, and the
// client's error quotes the URL.
job := pushJob(root, sum)
runBackupPush(job, "https://127.0.0.1:1/b/x?X-Amz-Signature=SECRETSIG&X-Amz-Credential=CRED", filepath.Join(root, fixtureData))
if job.State != "failed" {
t.Fatalf("state %s", job.State)
}
if strings.Contains(job.Error, "SECRETSIG") || strings.Contains(job.Error, "CRED") {
t.Fatalf("the job's error quotes the signature: %s", job.Error)
}
}
// stubPublicDNS answers every name with a public address, so start's URL
// check passes without the network; nothing is dialled in these tests.
func stubPublicDNS(t *testing.T) {
t.Helper()
prev := backupLookupIP
backupLookupIP = func(name string) ([]net.IP, error) {
if ip := net.ParseIP(name); ip != nil {
return []net.IP{ip}, nil
}
return []net.IP{net.ParseIP("203.0.113.9")}, nil
}
t.Cleanup(func() { backupLookupIP = prev })
}
func awaitPush(t *testing.T, root, id string) map[string]string {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
out, _, _ := driveBackupOp(t, "backup_push", "", root, backupPushReq{Op: "status", JobID: id})
var st map[string]string
_ = json.Unmarshal([]byte(out), &st)
if st["state"] != "pending" {
return st
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("push never left pending")
return nil
}
// Start answers at once without reading the file: a wrong digest is not a
// refusal at start but the job's failure, and the job is this pairing's own.
func TestBackupPushStartAnswersAtOnceAndStatusIsPerPairing(t *testing.T) {
stubPublicDNS(t)
root := t.TempDir()
rel, _ := pushFixture(t, root, []byte("a backup"))
out, errOut, code := driveBackupOp(t, "backup_push", "", root,
backupPushReq{Op: "start", Path: rel, URL: "https://storage.example/put?sig=1", SHA256: strings.Repeat("ab", 32), Headers: signedPut})
if code != 0 {
t.Fatalf("start refused: %s", errOut)
}
var started map[string]string
if json.Unmarshal([]byte(out), &started) != nil || started["state"] != "pending" || !strings.HasPrefix(started["job_id"], "bp-") {
t.Fatalf("start reply: %s", out)
}
if !regexp.MustCompile(`^bp-[0-9a-f]{24}$`).MatchString(started["job_id"]) {
t.Fatalf("job id %q is short enough to guess", started["job_id"])
}
st := awaitPush(t, root, started["job_id"])
if st["state"] != "failed" || !strings.Contains(st["error"], "sha256 differs") || st["job_id"] != started["job_id"] {
t.Fatalf("the job: %v", st)
}
other, _, _ := driveBackupOp(t, "backup_push", "", t.TempDir(), backupPushReq{Op: "status", JobID: started["job_id"]})
var o map[string]string
if json.Unmarshal([]byte(other), &o) != nil || o["state"] != "unknown" || o["error"] != "" {
t.Fatalf("another pairing read this job: %s", other)
}
}
// Through the real frame path: each refusal happens at start, before any job.
func TestBackupPushStartRefuses(t *testing.T) {
stubPublicDNS(t)
root := t.TempDir()
rel, sum := pushFixture(t, root, []byte("a backup"))
outsideDir := t.TempDir()
outside := filepath.Join(outsideDir, "20260101T000000Z.tar.zst.age")
_ = os.WriteFile(outside, []byte("not this host's"), 0o600)
_ = os.Symlink(outside, filepath.Join(root, "20260101T000000Z.tar.zst.age"))
_ = os.Symlink(filepath.Join(root, fixtureData), filepath.Join(root, "20260102T000000Z.tar.zst.age"))
_ = os.WriteFile(filepath.Join(root, "notes.txt"), []byte("mine"), 0o600)
_ = os.MkdirAll(filepath.Join(root, "sub"), 0o700)
_ = os.WriteFile(filepath.Join(root, "sub", fixtureData), []byte("a backup"), 0o600)
_ = os.Symlink(outsideDir, filepath.Join(root, "linkdir"))
type want struct {
req backupPushReq
why string
}
u := "https://storage.example/x"
cases := map[string]want{
"parent escape": {backupPushReq{Op: "start", Path: "../x/" + fixtureData, URL: u, SHA256: sum, Headers: signedPut}, "path rejected"},
"absolute path": {backupPushReq{Op: "start", Path: outside, URL: u, SHA256: sum, Headers: signedPut}, "path rejected"},
"not a backup name": {backupPushReq{Op: "start", Path: "notes.txt", URL: u, SHA256: sum, Headers: signedPut}, "path rejected"},
"a subfolder": {backupPushReq{Op: "start", Path: "sub/" + fixtureData, URL: u, SHA256: sum, Headers: signedPut}, "path rejected"},
"through a linked dir": {backupPushReq{Op: "start", Path: "linkdir/20260101T000000Z.tar.zst.age", URL: u, SHA256: sum, Headers: signedPut}, "path rejected"},
"link out of the root": {backupPushReq{Op: "start", Path: "20260101T000000Z.tar.zst.age", URL: u, SHA256: sum, Headers: signedPut}, "path rejected"},
"link inside the root": {backupPushReq{Op: "start", Path: "20260102T000000Z.tar.zst.age", URL: u, SHA256: sum, Headers: signedPut}, "no backup file"},
"missing file": {backupPushReq{Op: "start", Path: "20260103T000000Z.tar.zst.age", URL: u, SHA256: sum, Headers: signedPut}, "no backup file"},
"a folder not shared": {backupPushReq{Op: "start", Path: "shared/other/" + fixtureData, URL: u, SHA256: sum, Headers: signedPut}, "path rejected"},
"dot segments": {backupPushReq{Op: "start", Path: "./x/../" + fixtureData, URL: u, SHA256: sum, Headers: signedPut}, "path rejected"},
"bad digest": {backupPushReq{Op: "start", Path: rel, URL: u, SHA256: "abc", Headers: signedPut}, "64 hex"},
"plain http": {backupPushReq{Op: "start", Path: rel, URL: "http://storage.example/x", SHA256: sum, Headers: signedPut}, "must be https"},
"this machine": {backupPushReq{Op: "start", Path: rel, URL: "https://127.0.0.1/x", SHA256: sum, Headers: signedPut}, "on this machine"},
"private network": {backupPushReq{Op: "start", Path: rel, URL: "https://10.1.2.3/x", SHA256: sum, Headers: signedPut}, "on this machine"},
"unparseable signature": {backupPushReq{Op: "start", Path: rel, URL: "https://a%zz/x?X-Amz-Signature=SECRETSIG", SHA256: sum, Headers: signedPut}, "backup_push:"},
}
for name, c := range cases {
out, errOut, code := driveBackupOp(t, "backup_push", "", root, c.req)
if code == 0 {
t.Errorf("%s: started (%s)", name, out)
} else if !strings.Contains(errOut, "backup_push:") || !strings.Contains(errOut, c.why) {
t.Errorf("%s: refused for the wrong reason: %s", name, errOut)
}
if strings.Contains(errOut, "SECRETSIG") {
t.Errorf("%s: the refusal quotes the signature: %s", name, errOut)
}
}
}
// A folder the person shared with this pairing can be sent from; the same
// path from a pairing it was not shared with is refused.
func TestBackupPushFromASharedFolder(t *testing.T) {
allowLoopbackFetch(t)
const newHost = "new.host.privatae.ai"
lost := t.TempDir()
body := []byte("the lost host's backup")
_, sum := pushFixture(t, lost, body)
root := t.TempDir()
s, err := addBackupShare(newHost, root, lost, "old-host.example")
if err != nil {
t.Fatal(err)
}
p := "shared/" + s.Slug + "/" + fixtureData
abs, why := backupResolveFile(newHost, root, p)
if why != "" || abs != filepath.Join(lost, fixtureData) {
t.Fatalf("the shared file does not resolve: %q %q", abs, why)
}
other := t.TempDir()
if _, err := addBackupShare(newHost, root, other, "another"); err != nil {
t.Fatal(err)
}
if _, why := backupResolveFile(newHost, root, "shared/another/"+fixtureData); why == "" {
t.Fatal("a file named under one shared folder resolved from another")
}
if _, why := backupResolveFile(newHost, root, "shared/nope/"+fixtureData); why == "" {
t.Fatal("a folder that is not shared resolved")
}
for _, who := range []string{"other.host.privatae.ai", ""} {
if _, why := backupResolveFile(who, root, p); why == "" {
t.Fatalf("pairing %q, which the folder was not shared with, resolved it", who)
}
}
srv, got, _ := uploadServer(t, 200)
job := pushJob(root, sum)
runBackupPush(job, srv.URL+"/put", abs)
if job.State != "done" || string(*got) != string(body) {
t.Fatalf("%s %s", job.State, job.Error)
}
if err := dropBackupShare(newHost, s.Slug); err != nil {
t.Fatal(err)
}
if _, why := backupResolveFile(newHost, root, p); why == "" {
t.Fatal("a folder taken back still resolves")
}
}
// signedPut is what the platform's backup.put_url hands over with every
// upload address, and so what every start in these tests carries.
var signedPut = map[string]string{"If-None-Match": "*"}