audit 3: a restore is ended by its Resync, and half an archive is refused
test / test (push) Canceled after 0s
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:
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user