audit 3: backup CLI, envelope budget, directory sync, docs; findings of the reviews
Steps 3-6 of stage 3 and what the two reviews found in steps 1-6, in one commit: the review fixes sit in the same hunks. Step 3, CLI backup (M3, M5, L1, L2; L4 of the stage's list): - backup.snapshotDB refuses a database that is not there and opens the source mode=ro (measured: mode=ro creates no file). - selfpost-backup holds an encrypting password to validate.MinSecretFilePasswordLen; -o of both directions writes a temporary file beside the name and renames it whole; -decrypt peeks the first chunk (password and type authenticated) before the output exists. - what a backup leaves out (a link under data/, a link under certs/ to a directory, a socket...) is told to OnWarn; certs/ with no file is backup.ErrNoCerts, refused by Check and Create, named by the panel. - parseTrustedProxies: a bare address takes /128 when written with a colon (::ffff:10.0.0.1/32 was ::/32). Step 4, secretfile (M4, L3): scrypt's V at most 128 MiB, r <= 64, p <= 2; bytes after the last chunk are ErrCorrupt. Step 5 (L7): atomicfile.SyncDir after the rename, used by Write, replaceSASLDB and the CLI; atomicfile_test.go. Step 6: security.md and guide on backup and restore. Reviews (technical, security): - H1: a restore of a server with DMARC on was refused on every start - the archive records no owner, tar as root left the DMARC maps root's, and the entrypoint only chowned the maps it named. It now hands every file beside queue/ in /data/postfix to panel:selfpost 0640; e2e writes an extra map before the backup and finds it panel:selfpost 640 after a root unpack. - the comparison reads regular files only (Lstat, size, LimitReader); a record unpacked again wins over the earlier check's mark; ConsumeRestore removes both; the mark is synced; a journal file beside a freshly unpacked database is refused. - AttachRefusalLine refuses an empty line as the old statement did; skipped entries named in words; -decrypt of an empty payload is no failure; guide wording on the shell's '>' and a killed CLI; the in-place restore's 'mv .' (older than this stage) fixed. Tested on the test server: go vet + go test of 25 packages, make e2e (all pass, 643 s) on this tree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+82
-15
@@ -166,7 +166,7 @@ func Create(w io.Writer, p Params) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := addTree(a, p.DataDir, DataArchivePrefix, excludedFromArchive, skipQueue, false); err != nil {
|
||||
if _, err := addTree(a, p.DataDir, DataArchivePrefix, excludedFromArchive, skipQueue, false, p.OnWarn); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@ func Check(p Params) error {
|
||||
if err := ValidateDeployRoot(p.DeployRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := addTree(nil, p.DataDir, DataArchivePrefix, excludedFromArchive, skipQueue, false); err != nil {
|
||||
if _, err := addTree(nil, p.DataDir, DataArchivePrefix, excludedFromArchive, skipQueue, false, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
return addDeployFiles(nil, p.DeployRoot, nil)
|
||||
@@ -297,20 +297,43 @@ func addDeployFiles(a *archive, deployRoot string, onWarn func(string)) error {
|
||||
// are links again, relative, into archive/ (eff-certbot docs, "Where are
|
||||
// my certificates?"; assumptions C-Q4, C-A1). The archive carries the
|
||||
// files they lead to, under the links' names.
|
||||
return addTree(a, certsDir, CertsDirName+"/", nil, nil, true)
|
||||
files, err := addTree(a, certsDir, CertsDirName+"/", nil, nil, true, onWarn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// certs/ is what Postfix serves its certificate from (the compose file
|
||||
// mounts it at /etc/postfix/tls). There and empty - or holding only what
|
||||
// the archive cannot carry - it would give an archive that restores a
|
||||
// server without its certificate, so there is none (audit 2026-10-12,
|
||||
// L2).
|
||||
if files == 0 {
|
||||
return ErrNoCerts
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrNoCerts is a certs/ that is there with no file a backup can carry.
|
||||
var ErrNoCerts = errors.New("backup: " + CertsDirName + "/ holds no file this backup can carry, so the server it restores would have no certificate - put the PEM files (or links to them) there")
|
||||
|
||||
// addTree walks root and adds every regular file (and directory, to preserve
|
||||
// empty ones and modes) to a under archivePrefix + path relative to root.
|
||||
// empty ones and modes) to a under archivePrefix + path relative to root, and
|
||||
// returns how many files it took.
|
||||
// When exclude is non-nil, top-level names relative to root are skipped; skip,
|
||||
// when non-nil, leaves out whatever it says. With a nil a nothing is written:
|
||||
// every file is opened and closed, which is how Check finds what cannot be read.
|
||||
// Non-regular, non-directory entries (symlinks, sockets) are skipped - except,
|
||||
// with follow, a symbolic link to a regular file, which is archived as that
|
||||
// file under the link's name; a link that leads nowhere this process can
|
||||
// reach is an error, as an unreadable file is.
|
||||
func addTree(a *archive, root, archivePrefix string, exclude map[string]bool, skip skipFunc, follow bool) error {
|
||||
return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
// reach is an error, as an unreadable file is. Each entry left out that way
|
||||
// is told to warn, when it is not nil: a backup does not leave something out
|
||||
// without saying so (audit 2026-10-12, L2).
|
||||
func addTree(a *archive, root, archivePrefix string, exclude map[string]bool, skip skipFunc, follow bool, warn func(string)) (files int, err error) {
|
||||
skipped := func(name, what string) {
|
||||
if warn != nil {
|
||||
warn(name + " is " + what + ", not a file: the backup does not carry it")
|
||||
}
|
||||
}
|
||||
err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -362,6 +385,9 @@ func addTree(a *archive, root, archivePrefix string, exclude map[string]bool, sk
|
||||
// the listing and the open: not part of /data's state.
|
||||
return nil
|
||||
}
|
||||
if err == nil {
|
||||
files++
|
||||
}
|
||||
return err
|
||||
case follow && info.Mode()&fs.ModeSymlink != 0:
|
||||
target, err := os.Stat(path)
|
||||
@@ -369,13 +395,37 @@ func addTree(a *archive, root, archivePrefix string, exclude map[string]bool, sk
|
||||
return fmt.Errorf("backup: %s is a link to a file this container cannot reach - with certbot, /etc/letsencrypt is mounted at the same path (guide § Reverse proxy): %w", name, err)
|
||||
}
|
||||
if !target.Mode().IsRegular() {
|
||||
skipped(name, "a link to a directory")
|
||||
return nil
|
||||
}
|
||||
return writeTarFile(a, name, target.Mode().Perm(), path)
|
||||
if err := writeTarFile(a, name, target.Mode().Perm(), path); err != nil {
|
||||
return err
|
||||
}
|
||||
files++
|
||||
return nil
|
||||
case info.Mode()&fs.ModeSymlink != 0:
|
||||
skipped(name, "a link")
|
||||
return nil
|
||||
default:
|
||||
skipped(name, kindOf(info.Mode()))
|
||||
return nil
|
||||
}
|
||||
})
|
||||
return files, err
|
||||
}
|
||||
|
||||
// kindOf names what a non-regular, non-directory entry is, for a warning.
|
||||
func kindOf(m fs.FileMode) string {
|
||||
switch {
|
||||
case m&fs.ModeSocket != 0:
|
||||
return "a socket"
|
||||
case m&fs.ModeNamedPipe != 0:
|
||||
return "a named pipe"
|
||||
case m&fs.ModeDevice != 0:
|
||||
return "a device"
|
||||
default:
|
||||
return "neither a file nor a directory"
|
||||
}
|
||||
}
|
||||
|
||||
// writeTarBytes writes an in-memory file entry.
|
||||
@@ -430,6 +480,21 @@ func writeTarFile(a *archive, name string, mode fs.FileMode, srcPath string) err
|
||||
// applyQueryParams(): each _pragma is run as "pragma <value>" on connect,
|
||||
// busy_timeout first.)
|
||||
func snapshotDB(dbPath string) (path string, cleanup func(), err error) {
|
||||
// The database must be there: SQLite creates a file that is not, and an
|
||||
// archive of that empty database restores a panel that offers its
|
||||
// first-run setup on a server holding live keys (audit 2026-10-12, M3).
|
||||
// mode=ro makes the open fail rather than create one, should the file go
|
||||
// between the two: SQLite 3.53.4 creates a database only under "rwc", the
|
||||
// default (https://sqlite.org/uri.html § 3.3), and VACUUM INTO is not a
|
||||
// write to the source (lang_vacuum.html § 2.1, § 3) - both measured by
|
||||
// TestSnapshotRefusesAMissingDatabase.
|
||||
info, err := os.Stat(dbPath)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("backup: the database: %w", err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return "", nil, fmt.Errorf("backup: the database %s is not a file", dbPath)
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "selfpost-backup-")
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("backup: temp dir: %w", err)
|
||||
@@ -439,7 +504,7 @@ func snapshotDB(dbPath string) (path string, cleanup func(), err error) {
|
||||
|
||||
// A short busy timeout lets VACUUM INTO wait out a brief writer rather than
|
||||
// failing immediately if the panel happens to be mid-write.
|
||||
db, err := sql.Open("sqlite", "file:"+dbPath+"?_pragma=busy_timeout(5000)")
|
||||
db, err := sql.Open("sqlite", "file:"+dbPath+"?mode=ro&_pragma=busy_timeout(5000)")
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return "", nil, fmt.Errorf("backup: open database: %w", err)
|
||||
@@ -497,16 +562,18 @@ func CheckRestore(manifestPath, binaryVersion string) (restored bool, err error)
|
||||
|
||||
// ConsumeRestore ends a restore once its first start has done what a restore
|
||||
// start does: the manifest is removed, so later starts (and in-place
|
||||
// upgrades) are not gated by it, and then the record of the checked files. A
|
||||
// start stopped between the two leaves the record alone, which nothing reads
|
||||
// without a manifest and no backup carries.
|
||||
// upgrades) are not gated by it, and then the record of the files, checked or
|
||||
// unpacked again since. A start stopped between the two leaves the record
|
||||
// alone, which nothing reads without a manifest and no backup carries.
|
||||
func ConsumeRestore(manifestPath string) error {
|
||||
if err := os.Remove(manifestPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("backup: consume restore manifest: %w", err)
|
||||
}
|
||||
checked := filepath.Join(filepath.Dir(manifestPath), contentsCheckedName)
|
||||
if err := os.Remove(checked); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("backup: consume restore manifest: %w", err)
|
||||
for _, name := range []string{contentsCheckedName, ContentsName} {
|
||||
err := os.Remove(filepath.Join(filepath.Dir(manifestPath), name))
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("backup: consume restore manifest: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -522,3 +523,143 @@ func TestCertsLinksAreFollowed(t *testing.T) {
|
||||
t.Fatalf("Check with a dangling link: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A wrong SELFPOST_DB_PATH is refused, and nothing is created where it
|
||||
// points: SQLite makes a database of a path that is not one, and an archive
|
||||
// of that empty database restores a server that offers its first-run setup
|
||||
// (audit 2026-10-12, M3). The snapshot opens the source read-only, which is
|
||||
// what refuses it should the file go between the check and the open.
|
||||
func TestSnapshotRefusesAMissingDatabase(t *testing.T) {
|
||||
dataDir, _, deployRoot := seedDataDir(t)
|
||||
missing := filepath.Join(dataDir, "elsewhere.db")
|
||||
var buf bytes.Buffer
|
||||
err := Create(&buf, Params{DataDir: dataDir, DBPath: missing, Version: "1.0.0", DeployRoot: deployRoot})
|
||||
if err == nil {
|
||||
t.Fatal("a backup of a database that is not there was made")
|
||||
}
|
||||
if _, statErr := os.Stat(missing); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("the refused backup created %s (stat err = %v)", missing, statErr)
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", "file:"+missing+"?mode=ro")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := db.Ping(); err == nil {
|
||||
t.Fatal("mode=ro opened a database that is not there")
|
||||
}
|
||||
if _, statErr := os.Stat(missing); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("mode=ro created %s (stat err = %v)", missing, statErr)
|
||||
}
|
||||
}
|
||||
|
||||
// certs/ there and empty is refused by Check and Create alike: the archive
|
||||
// would restore a server without its certificate (audit 2026-10-12, L2).
|
||||
func TestEmptyCertsIsRefused(t *testing.T) {
|
||||
dataDir, dbPath, deployRoot := seedDataDir(t)
|
||||
for _, f := range []string{"fullchain.pem", "privkey.pem"} {
|
||||
os.Remove(filepath.Join(deployRoot, CertsDirName, f))
|
||||
}
|
||||
p := Params{DataDir: dataDir, DBPath: dbPath, Version: "1.0.0", DeployRoot: deployRoot}
|
||||
if err := Check(p); err == nil || !strings.Contains(err.Error(), "certs/ holds no file") {
|
||||
t.Errorf("Check with an empty certs/ = %v", err)
|
||||
}
|
||||
if err := Create(io.Discard, p); err == nil || !strings.Contains(err.Error(), "certs/ holds no file") {
|
||||
t.Errorf("Create with an empty certs/ = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// What a backup leaves out it says it leaves out: a link under data/, and a
|
||||
// link under certs/ to a directory (audit 2026-10-12, L2).
|
||||
func TestBackupWarnsOfWhatItLeavesOut(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symbolic links need privileges on Windows; the container is Linux")
|
||||
}
|
||||
dataDir, dbPath, deployRoot := seedDataDir(t)
|
||||
if err := os.Symlink("sasl/sasldb2", filepath.Join(dataDir, "a-link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(t.TempDir(), filepath.Join(deployRoot, CertsDirName, "a-directory")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var warnings []string
|
||||
p := Params{DataDir: dataDir, DBPath: dbPath, Version: "1.0.0", DeployRoot: deployRoot,
|
||||
OnWarn: func(s string) { warnings = append(warnings, s) }}
|
||||
if err := Create(io.Discard, p); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
got := strings.Join(warnings, "\n")
|
||||
for _, want := range []string{
|
||||
"data/a-link is a link, not a file: the backup does not carry it",
|
||||
"certs/a-directory is a link to a directory, not a file: the backup does not carry it",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("no warning %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A link or a pipe where the record names a file is refused, not followed or
|
||||
// read: a crafted archive must not make the start read outside /data, or
|
||||
// wait (security review of audit stage 3).
|
||||
func TestCheckRestoreReadsOnlyRegularFiles(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symbolic links need privileges on Windows; the container is Linux")
|
||||
}
|
||||
path := unpackData(t, createArchive(t, "1.0.0"))
|
||||
dir := filepath.Dir(path)
|
||||
sasl := filepath.Join(dir, "sasl", "sasldb2")
|
||||
outside := filepath.Join(t.TempDir(), "outside")
|
||||
writeFile(t, outside, "SASLDB")
|
||||
os.Remove(sasl)
|
||||
if err := os.Symlink(outside, sasl); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := CheckRestore(path, "1.0.0"); err == nil || !strings.Contains(err.Error(), "data/sasl/sasldb2 is not the file") {
|
||||
t.Fatalf("a link to a file of the same contents = %v, want refused", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A journal file beside the restored database is from what the directory
|
||||
// held before; SQLite would take it up, so the restore is refused.
|
||||
func TestCheckRestoreRefusesAJournalBesideTheDatabase(t *testing.T) {
|
||||
for _, side := range []string{"-wal", "-shm", "-journal"} {
|
||||
path := unpackData(t, createArchive(t, "1.0.0"))
|
||||
writeFile(t, filepath.Join(filepath.Dir(path), "selfpost.db"+side), "old")
|
||||
if _, err := CheckRestore(path, "1.0.0"); err == nil || !strings.Contains(err.Error(), "selfpost.db"+side+" is beside the database") {
|
||||
t.Errorf("%s beside the database = %v, want refused", side, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The archive unpacked again after a start that checked it is checked again:
|
||||
// its record wins over the mark of the earlier check, and a file it cut short
|
||||
// is refused. A finished restore leaves neither record.
|
||||
func TestARecordUnpackedAgainIsChecked(t *testing.T) {
|
||||
archive := createArchive(t, "1.0.0")
|
||||
path := unpackData(t, archive)
|
||||
dir := filepath.Dir(path)
|
||||
if _, err := CheckRestore(path, "1.0.0"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
files := readArchive(t, archive)
|
||||
writeFile(t, filepath.Join(dir, ContentsName), files[DataArchivePrefix+ContentsName])
|
||||
writeFile(t, filepath.Join(dir, "sasl", "sasldb2"), "SAS")
|
||||
if _, err := CheckRestore(path, "1.0.0"); err == nil || !strings.Contains(err.Error(), "data/sasl/sasldb2 is not the file") {
|
||||
t.Fatalf("a second unpack cut short = %v, want refused", err)
|
||||
}
|
||||
writeFile(t, filepath.Join(dir, "sasl", "sasldb2"), files[DataArchivePrefix+"sasl/sasldb2"])
|
||||
if _, err := CheckRestore(path, "1.0.0"); err != nil {
|
||||
t.Fatalf("the second unpack whole: %v", err)
|
||||
}
|
||||
writeFile(t, filepath.Join(dir, ContentsName), files[DataArchivePrefix+ContentsName])
|
||||
if err := ConsumeRestore(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{ManifestName, ContentsName, contentsCheckedName} {
|
||||
if _, err := os.Stat(filepath.Join(dir, name)); !os.IsNotExist(err) {
|
||||
t.Errorf("%s left after ConsumeRestore", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+44
-15
@@ -12,6 +12,8 @@ import (
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/atomicfile"
|
||||
)
|
||||
|
||||
// FormatContents identifies the record of what an archive's data directory
|
||||
@@ -93,34 +95,49 @@ func errUnpackedPart(detail string) error {
|
||||
}
|
||||
|
||||
// checkContents compares the data directory with the archive's record, once
|
||||
// per restore (see contentsCheckedName).
|
||||
// per restore (see contentsCheckedName). A record unpacked anew wins over the
|
||||
// mark of an earlier check: the archive was unpacked again, and that is what
|
||||
// the start runs on (review of audit stage 3).
|
||||
func checkContents(dataDir string) error {
|
||||
checked := filepath.Join(dataDir, contentsCheckedName)
|
||||
if c, err := readContents(checked); err == nil {
|
||||
// A start before this one checked the files. It is the same restore
|
||||
// when the manifest is still the one that record holds; a different
|
||||
// archive unpacked over the directory since is checked anew.
|
||||
if verifyFiles(dataDir, c, DataArchivePrefix+ManifestName) == nil {
|
||||
return nil
|
||||
}
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
|
||||
path := filepath.Join(dataDir, ContentsName)
|
||||
checked := filepath.Join(dataDir, contentsCheckedName)
|
||||
c, err := readContents(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
prev, perr := readContents(checked)
|
||||
if perr == nil && verifyFiles(dataDir, prev, DataArchivePrefix+ManifestName) == nil {
|
||||
// A start before this one checked the files, and the manifest is
|
||||
// still the one that record holds: the same restore, whose
|
||||
// files that start may have changed since.
|
||||
return nil
|
||||
}
|
||||
if perr != nil && !errors.Is(perr, os.ErrNotExist) {
|
||||
return perr
|
||||
}
|
||||
return errUnpackedPart(DataArchivePrefix + ContentsName + ", its last entry, is not there")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// No backup carries the database's journal files. One beside the
|
||||
// restored database is left from the database the directory held
|
||||
// before, and opening this one would take it up: the first connection
|
||||
// after a crash recovers from the WAL, and a checkpoint "takes content
|
||||
// from the WAL file and transfers it back into the original database
|
||||
// file" (https://sqlite.org/wal.html § 9, § 2.2).
|
||||
for _, side := range []string{"-wal", "-shm", "-journal"} {
|
||||
if _, err := os.Lstat(filepath.Join(dataDir, "selfpost.db"+side)); err == nil {
|
||||
return errUnpackedPart("selfpost.db" + side + " is beside the database, and no archive holds one - it is left from what was in the directory before")
|
||||
}
|
||||
}
|
||||
if err := verifyFiles(dataDir, c, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(path, checked); err != nil {
|
||||
return fmt.Errorf("backup: mark the restored files checked: %w", err)
|
||||
}
|
||||
// The start goes on to change the files; the mark must not be lost to a
|
||||
// power cut after that, or the next start compares changed files.
|
||||
_ = atomicfile.SyncDir(dataDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -151,15 +168,27 @@ func verifyFiles(dataDir string, c Contents, only string) error {
|
||||
if !ok || rel == "" || path.Clean(rel) != rel || rel == ".." || strings.HasPrefix(rel, "../") || path.IsAbs(rel) {
|
||||
return errUnpackedPart("the record names " + f.Name + ", which is not a file of the data directory")
|
||||
}
|
||||
file, err := os.Open(filepath.Join(dataDir, filepath.FromSlash(rel)))
|
||||
// The archive holds regular files only (archive.writeFile); a link
|
||||
// or a pipe under the name is not followed or read - a crafted
|
||||
// archive must not make the start read a file outside /data, or
|
||||
// wait on a pipe (security review of audit stage 3).
|
||||
full := filepath.Join(dataDir, filepath.FromSlash(rel))
|
||||
info, err := os.Lstat(full)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return errUnpackedPart(f.Name + " is missing")
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("backup: check %s: %w", f.Name, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Size() != f.Size {
|
||||
return errUnpackedPart(f.Name + " is not the file the archive holds")
|
||||
}
|
||||
file, err := os.Open(full)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backup: check %s: %w", f.Name, err)
|
||||
}
|
||||
h := sha256.New()
|
||||
n, err := io.Copy(h, file)
|
||||
n, err := io.Copy(h, io.LimitReader(file, f.Size+1))
|
||||
file.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("backup: check %s: %w", f.Name, err)
|
||||
|
||||
Reference in New Issue
Block a user