audit 3: a restore is ended by its Resync, and half an archive is refused
test / test (push) Canceled after 0s

The restore manifest was consumed as soon as its version was checked,
before store.Open and the Resync; a start that failed in between left a
data directory the next start took for an ordinary one (M1). openData now
runs CheckRestore, store.Open, the Resync, and only then ConsumeRestore.

A truncated archive had nothing to refuse it: the manifest is the first
entry, so a download cut short or a tar stopped by a damaged block was a
restore on half a database. Every archive now ends with data/contents.json,
the size and SHA-256 of each data/ file (deploy files are the operator's to
edit), and CheckRestore compares /data with it before the database is
opened, then renames it contents.checked.json so a start after a failed one
does not compare again unless the manifest changed.

Tests: a start whose Resync fails, then a start that resyncs and consumes;
an archive cut in the database, before the deploy files and in its last
entry (refused, database untouched, no -wal); a damaged key named; the
record's names confined to data/; the domain import of a cut plain export,
a cut and an altered .spde (refused, nothing created). e2e on the test
server: full_backup_restore now also sees contents.json in the archive and
gone after the first start, through the real entrypoint and daemons (all
pass, 762 s); go vet + go test of 24 packages pass there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-15 09:53:46 +03:00
co-authored by Claude Opus 5.5
parent 68b3a7b23f
commit 8e3241ef98
13 changed files with 785 additions and 101 deletions
@@ -3,6 +3,7 @@ package handlers
import (
"bytes"
"encoding/json"
"html"
"mime/multipart"
"net/http"
"net/http/httptest"
@@ -10,6 +11,7 @@ import (
"testing"
"github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/secretfile"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
)
@@ -116,3 +118,57 @@ func TestDomainImportRefusedCreatesNothing(t *testing.T) {
t.Fatalf("the refused import created %v", domains)
}
}
// A domain export cut short or altered on the way is refused with what is
// wrong with it, before anything is created (audit 2026-10-12, stage 3).
func TestDomainImportOfADamagedFileCreatesNothing(t *testing.T) {
h, st := inboundHandlers(t)
root := userWith(t, st, "root", store.RoleGlobal, store.Reach{})
plain, _ := json.Marshal(domain.DomainExport{
Format: domain.FormatDomainExport, Domain: "example.com", DKIMSelector: "mail",
Applications: []domain.AppExport{{Login: "app1", AddressMode: store.AddressModeWildcard, Password: "imported-password"}},
})
const password = "a-long-enough-password"
var sealed bytes.Buffer
w, err := secretfile.NewWriter(&sealed, secretfile.TypeDomainExport, password)
if err != nil {
t.Fatal(err)
}
w.Write(plain)
w.Close()
flipped := bytes.Clone(sealed.Bytes())
flipped[len(flipped)-20] ^= 1
for _, c := range []struct {
name, file, password string
body []byte
want string
}{
{"plain, cut", "example.com.json", "", plain[:len(plain)/2], "That file is not a valid SelfPost domain export."},
{"encrypted, cut", "example.com.spde", password, sealed.Bytes()[:sealed.Len()-10], "That file is damaged or incomplete."},
{"encrypted, altered", "example.com.spde", password, flipped, "Wrong password, or the file has been altered since it was exported."},
} {
t.Run(c.name, func(t *testing.T) {
var body bytes.Buffer
mw := multipart.NewWriter(&body)
fw, _ := mw.CreateFormFile("file", c.file)
fw.Write(c.body)
mw.WriteField("import_password", c.password)
mw.Close()
req := httptest.NewRequest("POST", "/server/backup/import", &body)
req.Header.Set("Content-Type", mw.FormDataContentType())
req = auth.RequestWithPrincipal(req, root)
rec := httptest.NewRecorder()
h.HandleImportDomain(rec, req)
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), html.EscapeString(c.want)) {
t.Fatalf("import = %d, want 400 saying %q:\n%s", rec.Code, c.want, rec.Body.String())
}
if domains, _ := st.ListDomains(); len(domains) != 0 {
t.Fatalf("the refused import created %v", domains)
}
if _, err := st.GetApplicationByLogin("app1"); err == nil {
t.Fatal("the refused import created the application")
}
})
}
}