Initial commit: DSH Web Manager
This commit is contained in:
@@ -0,0 +1,515 @@
|
||||
// Package backup handles configuration archives. Callers must stop containers,
|
||||
// exclude concurrent filesystem writers, and manage rollback across files.
|
||||
package backup
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxTotalBytes = 32 << 20
|
||||
MaxFiles = 4096
|
||||
maxArchiveBytes = 4*((MaxTotalBytes+2)/3) + 4096
|
||||
magic = "DSHBACKUP\x01"
|
||||
magicV2 = "DSHBACKUP\x02"
|
||||
SchemaVersion = 2
|
||||
maxManifestBytes = 4096
|
||||
maxTarBytes = MaxTotalBytes + MaxFiles*4096 + maxManifestBytes
|
||||
)
|
||||
|
||||
var allowed = [...]string{".credentials.yaml", "cordis.patch.yml", "profiles/web/package.json", "profiles/web/cordis.patch.yml", "profiles/web/pnpm-lock.yaml", "dsh-ssh.json"}
|
||||
var resources = [...]string{"skins", "pets", "agent-presets", ".agent-presets"}
|
||||
|
||||
type Metadata struct {
|
||||
DSHVersion string `json:"dsh_version"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type manifest struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Metadata
|
||||
}
|
||||
|
||||
func allowedPath(name string, recursive bool) bool {
|
||||
if name == "." || len(name) > 1024 || path.Clean(name) != name || strings.ContainsAny(name, "\\\x00:") || path.IsAbs(name) {
|
||||
return false
|
||||
}
|
||||
for _, valid := range allowed {
|
||||
if name == valid {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if recursive {
|
||||
for _, dir := range resources {
|
||||
if strings.HasPrefix(name, dir+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type payload struct {
|
||||
Version int `json:"version"`
|
||||
Files map[string][]byte `json:"files"`
|
||||
}
|
||||
|
||||
func newGCM(key []byte) (cipher.AEAD, error) {
|
||||
b, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cipher.NewGCM(b)
|
||||
}
|
||||
|
||||
func openHome(home string) (*os.Root, error) {
|
||||
info, err := os.Lstat(home)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return nil, errors.New("home must not be a symlink")
|
||||
}
|
||||
return os.OpenRoot(home)
|
||||
}
|
||||
|
||||
// os.Root permits in-root symlinks, so reject them explicitly in each component.
|
||||
func checkPath(root *os.Root, name string) error {
|
||||
parts := strings.Split(name, "/")
|
||||
for i := range parts {
|
||||
info, err := root.Lstat(strings.Join(parts[:i+1], "/"))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("symlink rejected: %s", name)
|
||||
}
|
||||
if i < len(parts)-1 && !info.IsDir() {
|
||||
return fmt.Errorf("not a directory: %s", name)
|
||||
}
|
||||
if i == len(parts)-1 && !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("not a regular file: %s", name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readBounded(f *os.File, limit int64) ([]byte, error) {
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, errors.New("not a regular file")
|
||||
}
|
||||
if info.Size() > limit {
|
||||
return nil, errors.New("backup size limit exceeded")
|
||||
}
|
||||
b, err := io.ReadAll(io.LimitReader(f, limit+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(b)) > limit {
|
||||
return nil, errors.New("backup size limit exceeded")
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// Create writes a 0600 archive using a raw 16, 24, or 32 byte AES key.
|
||||
// Missing allowlisted files are omitted; destination's parent must exist.
|
||||
func Create(home, destination string, key []byte) error {
|
||||
return CreateWithMetadata(home, destination, key, Metadata{})
|
||||
}
|
||||
|
||||
// CreateWithMetadata writes an AES-GCM encrypted tar.gz with an authenticated
|
||||
// manifest. Empty resource directories are omitted; callers must exclude writers.
|
||||
func CreateWithMetadata(home, destination string, key []byte, metadata Metadata) error {
|
||||
gcm, err := newGCM(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
root, err := openHome(home)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer root.Close()
|
||||
p := payload{Version: SchemaVersion, Files: make(map[string][]byte)}
|
||||
remaining := int64(MaxTotalBytes)
|
||||
addFile := func(name string) error {
|
||||
if !allowedPath(name, true) {
|
||||
return fmt.Errorf("path not allowed: %q", name)
|
||||
}
|
||||
if err := checkPath(root, name); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := root.Open(name)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, readErr := readBounded(f, remaining)
|
||||
closeErr := f.Close()
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("%s: %w", name, readErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
remaining -= int64(len(data))
|
||||
p.Files[name] = data
|
||||
if len(p.Files) > MaxFiles {
|
||||
return errors.New("backup file count limit exceeded")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, name := range allowed {
|
||||
if err := addFile(name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
visited := 0
|
||||
var walk func(string) error
|
||||
walk = func(name string) error {
|
||||
visited++
|
||||
if visited > MaxFiles*2 || len(name) > 1024 {
|
||||
return errors.New("resource traversal limit exceeded")
|
||||
}
|
||||
info, err := root.Lstat(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return addFile(name)
|
||||
}
|
||||
dir, err := root.Open(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dir.Close()
|
||||
for {
|
||||
entries, err := dir.ReadDir(1)
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if err := walk(name + "/" + entry.Name()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, name := range resources {
|
||||
info, err := root.Lstat(name)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("resource root is not a directory: %s", name)
|
||||
}
|
||||
if err := walk(name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
plain, err := encodeV2(p.Files, metadata)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return err
|
||||
}
|
||||
archive := append([]byte(magicV2), nonce...)
|
||||
archive = gcm.Seal(archive, nonce, plain, []byte(magicV2))
|
||||
if len(archive) > maxArchiveBytes {
|
||||
return errors.New("backup archive size limit exceeded")
|
||||
}
|
||||
destRoot, err := os.OpenRoot(filepath.Dir(destination))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer destRoot.Close()
|
||||
return replace(destRoot, filepath.Base(destination), archive)
|
||||
}
|
||||
|
||||
// Restore authenticates and validates everything before changing home. Files
|
||||
// absent from the archive remain untouched. Replacement is atomic per file only.
|
||||
func Restore(archive, home string, key []byte) error {
|
||||
gcm, err := newGCM(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
aroot, err := os.OpenRoot(filepath.Dir(archive))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer aroot.Close()
|
||||
if err := checkPath(aroot, filepath.Base(archive)); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := aroot.Open(filepath.Base(archive))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, readErr := readBounded(f, maxArchiveBytes)
|
||||
closeErr := f.Close()
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
if len(data) < len(magic)+gcm.NonceSize()+gcm.Overhead() || (!bytes.HasPrefix(data, []byte(magic)) && !bytes.HasPrefix(data, []byte(magicV2))) {
|
||||
return errors.New("invalid backup format")
|
||||
}
|
||||
nonce := data[len(magic) : len(magic)+gcm.NonceSize()]
|
||||
header := string(data[:len(magic)])
|
||||
plain, err := gcm.Open(nil, nonce, data[len(magic)+gcm.NonceSize():], []byte(header))
|
||||
if err != nil {
|
||||
return fmt.Errorf("authenticate backup: %w", err)
|
||||
}
|
||||
var p payload
|
||||
if header == magicV2 {
|
||||
p.Files, err = decodeV2(plain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := decodeJSON(plain, &p); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Version != 1 || p.Files == nil {
|
||||
return errors.New("unsupported backup payload")
|
||||
}
|
||||
}
|
||||
if len(p.Files) > MaxFiles {
|
||||
return errors.New("backup file count limit exceeded")
|
||||
}
|
||||
total := 0
|
||||
for name, data := range p.Files {
|
||||
if !allowedPath(name, header == magicV2) {
|
||||
return fmt.Errorf("path not allowed: %q", name)
|
||||
}
|
||||
for parent := path.Dir(name); parent != "."; parent = path.Dir(parent) {
|
||||
if _, exists := p.Files[parent]; exists {
|
||||
return errors.New("conflicting backup paths")
|
||||
}
|
||||
}
|
||||
total += len(data)
|
||||
if total > MaxTotalBytes {
|
||||
return errors.New("backup size limit exceeded")
|
||||
}
|
||||
}
|
||||
root, err := openHome(home)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer root.Close()
|
||||
for name := range p.Files {
|
||||
if err := checkPath(root, name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for name, data := range p.Files {
|
||||
if err := root.MkdirAll(filepath.Dir(name), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := replace(root, name, data); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeJSON(data []byte, value any) error {
|
||||
d := json.NewDecoder(bytes.NewReader(data))
|
||||
d.DisallowUnknownFields()
|
||||
if err := d.Decode(value); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.Decode(new(any)); err != io.EOF {
|
||||
return errors.New("trailing backup data")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func encodeV2(files map[string][]byte, metadata Metadata) ([]byte, error) {
|
||||
if metadata.CreatedAt.IsZero() {
|
||||
metadata.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
if metadata.DSHVersion == "" {
|
||||
metadata.DSHVersion = "unknown"
|
||||
}
|
||||
data, err := json.Marshal(manifest{SchemaVersion, metadata})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) > maxManifestBytes {
|
||||
return nil, errors.New("manifest size limit exceeded")
|
||||
}
|
||||
var out bytes.Buffer
|
||||
gz := gzip.NewWriter(&out)
|
||||
tw := tar.NewWriter(gz)
|
||||
write := func(name string, data []byte) error {
|
||||
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0600, Size: int64(len(data)), Typeflag: tar.TypeReg, Format: tar.FormatPAX}); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tw.Write(data)
|
||||
return err
|
||||
}
|
||||
if err := write("manifest.json", data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make([]string, 0, len(files))
|
||||
for name := range files {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
if err := write(name, files[name]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
func decodeV2(plain []byte) (map[string][]byte, error) {
|
||||
source := bytes.NewReader(plain)
|
||||
gz, err := gzip.NewReader(source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer gz.Close()
|
||||
gz.Multistream(false)
|
||||
unpacked, err := io.ReadAll(io.LimitReader(gz, maxTarBytes+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(unpacked) > maxTarBytes {
|
||||
return nil, errors.New("expanded archive size limit exceeded")
|
||||
}
|
||||
if source.Len() != 0 {
|
||||
return nil, errors.New("trailing gzip data")
|
||||
}
|
||||
input := bytes.NewReader(unpacked)
|
||||
tr := tar.NewReader(input)
|
||||
files := make(map[string][]byte)
|
||||
seenManifest := false
|
||||
remaining := int64(MaxTotalBytes)
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if h.Typeflag != tar.TypeReg || h.Linkname != "" {
|
||||
return nil, errors.New("non-regular archive entry")
|
||||
}
|
||||
limit := remaining
|
||||
if h.Name == "manifest.json" {
|
||||
if seenManifest {
|
||||
return nil, errors.New("duplicate manifest")
|
||||
}
|
||||
limit = maxManifestBytes
|
||||
} else {
|
||||
if !allowedPath(h.Name, true) {
|
||||
return nil, fmt.Errorf("path not allowed: %q", h.Name)
|
||||
}
|
||||
if _, exists := files[h.Name]; exists {
|
||||
return nil, errors.New("duplicate archive entry")
|
||||
}
|
||||
if len(files) >= MaxFiles {
|
||||
return nil, errors.New("backup file count limit exceeded")
|
||||
}
|
||||
}
|
||||
if h.Size < 0 || h.Size > limit {
|
||||
return nil, errors.New("backup size limit exceeded")
|
||||
}
|
||||
data, err := io.ReadAll(tr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if h.Name == "manifest.json" {
|
||||
var m manifest
|
||||
if err := decodeJSON(data, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m.SchemaVersion != SchemaVersion || m.CreatedAt.IsZero() || m.DSHVersion == "" {
|
||||
return nil, errors.New("invalid manifest")
|
||||
}
|
||||
seenManifest = true
|
||||
} else {
|
||||
files[h.Name] = data
|
||||
remaining -= int64(len(data))
|
||||
}
|
||||
}
|
||||
if !seenManifest || input.Len() != 0 {
|
||||
return nil, errors.New("missing manifest or trailing tar data")
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func replace(root *os.Root, name string, data []byte) error {
|
||||
if err := checkPath(root, name); err != nil {
|
||||
return err
|
||||
}
|
||||
var random [16]byte
|
||||
if _, err := rand.Read(random[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := filepath.Join(filepath.Dir(name), ".backup-"+hex.EncodeToString(random[:]))
|
||||
f, err := root.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer root.Remove(tmp)
|
||||
_, writeErr := f.Write(data)
|
||||
if writeErr == nil {
|
||||
writeErr = f.Sync()
|
||||
}
|
||||
closeErr := f.Close()
|
||||
if writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
if err := checkPath(root, name); err != nil {
|
||||
return err
|
||||
}
|
||||
return root.Rename(tmp, name)
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var testKey = bytes.Repeat([]byte{42}, 32)
|
||||
|
||||
func put(t *testing.T, name string, data []byte) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(name), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(name, data, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func sealed(t *testing.T, p payload) string {
|
||||
t.Helper()
|
||||
plain, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return sealPlain(t, magic, plain)
|
||||
}
|
||||
|
||||
func sealPlain(t *testing.T, header string, plain []byte) string {
|
||||
t.Helper()
|
||||
gcm, err := newGCM(testKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
data := append([]byte(header), nonce...)
|
||||
data = gcm.Seal(data, nonce, plain, []byte(header))
|
||||
name := filepath.Join(t.TempDir(), "archive")
|
||||
put(t, name, data)
|
||||
return name
|
||||
}
|
||||
|
||||
func TestResourcesAndManifest(t *testing.T) {
|
||||
home, dest := t.TempDir(), t.TempDir()
|
||||
for _, dir := range resources {
|
||||
put(t, filepath.Join(home, dir, "nested/资源.bin"), []byte{0, 1, 255})
|
||||
}
|
||||
for _, name := range []string{"node_modules/pkg", "worktrees/x", "sessions/x", "logs/x", "manifest.json"} {
|
||||
put(t, filepath.Join(home, name), []byte("excluded"))
|
||||
}
|
||||
metadata := Metadata{DSHVersion: "1.2.3", CreatedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)}
|
||||
archive := filepath.Join(t.TempDir(), "archive")
|
||||
if err := CreateWithMetadata(home, archive, testKey, metadata); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(archive)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.HasPrefix(data, []byte(magicV2)) {
|
||||
t.Fatal("not v2")
|
||||
}
|
||||
gcm, _ := newGCM(testKey)
|
||||
nonce := data[len(magicV2) : len(magicV2)+gcm.NonceSize()]
|
||||
plain, err := gcm.Open(nil, nonce, data[len(magicV2)+gcm.NonceSize():], []byte(magicV2))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gz, err := gzip.NewReader(bytes.NewReader(plain))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer gz.Close()
|
||||
tr := tar.NewReader(gz)
|
||||
h, err := tr.Next()
|
||||
if err != nil || h.Name != "manifest.json" {
|
||||
t.Fatal("missing manifest", err)
|
||||
}
|
||||
b, err := io.ReadAll(tr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m manifest
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.SchemaVersion != SchemaVersion || m.Metadata != metadata {
|
||||
t.Fatalf("manifest: %+v", m)
|
||||
}
|
||||
if err := Restore(archive, dest, testKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, dir := range resources {
|
||||
b, err := os.ReadFile(filepath.Join(dest, dir, "nested/资源.bin"))
|
||||
if err != nil || !bytes.Equal(b, []byte{0, 1, 255}) {
|
||||
t.Fatal(dir, err, b)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dest, "manifest.json")); !os.IsNotExist(err) {
|
||||
t.Fatal("manifest restored into home")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyRestore(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
if err := Restore(sealed(t, payload{1, map[string][]byte{allowed[0]: []byte("legacy")}}), home, testKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(home, allowed[0]))
|
||||
if err != nil || string(b) != "legacy" {
|
||||
t.Fatal(err, string(b))
|
||||
}
|
||||
}
|
||||
|
||||
type tarEntry struct {
|
||||
name string
|
||||
kind byte
|
||||
data []byte
|
||||
}
|
||||
|
||||
func makeTar(t *testing.T, entries []tarEntry) []byte {
|
||||
t.Helper()
|
||||
var b bytes.Buffer
|
||||
gz := gzip.NewWriter(&b)
|
||||
tw := tar.NewWriter(gz)
|
||||
for _, e := range entries {
|
||||
h := &tar.Header{Name: e.name, Typeflag: e.kind, Mode: 0600, Size: int64(len(e.data))}
|
||||
if e.kind == tar.TypeSymlink || e.kind == tar.TypeLink {
|
||||
h.Linkname = "target"
|
||||
}
|
||||
if err := tw.WriteHeader(h); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tw.Write(e.data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
func TestV2RejectsInvalidArchivesBeforeWrites(t *testing.T) {
|
||||
m, _ := json.Marshal(manifest{SchemaVersion, Metadata{"test", time.Now().UTC()}})
|
||||
base := []tarEntry{{"manifest.json", tar.TypeReg, m}, {allowed[0], tar.TypeReg, []byte("new")}}
|
||||
cases := map[string][]tarEntry{
|
||||
"missing manifest": base[1:],
|
||||
"duplicate manifest": append(append([]tarEntry{}, base...), base[0]),
|
||||
"duplicate file": append(append([]tarEntry{}, base...), base[1]),
|
||||
"invalid manifest": {{"manifest.json", tar.TypeReg, []byte(`{"schema_version":3}`)}},
|
||||
"conflict": append(append([]tarEntry{}, base...), tarEntry{"pets/a", tar.TypeReg, nil}, tarEntry{"pets/a/b", tar.TypeReg, nil}),
|
||||
}
|
||||
for _, name := range []string{"pets/../escape", "pets//a", "pets/./a", "/pets/a", "pets/a/../../escape", "pets/a\\b", "pets/C:bad", "pets", "other/a"} {
|
||||
cases[name] = append(append([]tarEntry{}, base...), tarEntry{name, tar.TypeReg, nil})
|
||||
}
|
||||
for _, kind := range []byte{tar.TypeSymlink, tar.TypeLink, tar.TypeFifo, tar.TypeChar, tar.TypeBlock, tar.TypeDir} {
|
||||
cases[fmt.Sprint(kind)] = append(append([]tarEntry{}, base...), tarEntry{"pets/unsafe", kind, nil})
|
||||
}
|
||||
for name, entries := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
put(t, filepath.Join(home, allowed[0]), []byte("old"))
|
||||
if err := Restore(sealPlain(t, magicV2, makeTar(t, entries)), home, testKey); err == nil {
|
||||
t.Fatal("accepted invalid archive")
|
||||
}
|
||||
b, _ := os.ReadFile(filepath.Join(home, allowed[0]))
|
||||
if string(b) != "old" {
|
||||
t.Fatal("partial write")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceFilesystemRejections(t *testing.T) {
|
||||
for _, name := range []string{"pets", "pets/nested", "pets/nested/file"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Dir(filepath.Join(home, name)), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(t.TempDir(), filepath.Join(home, name)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Create(home, filepath.Join(t.TempDir(), "archive"), testKey); err == nil {
|
||||
t.Fatal("followed resource symlink")
|
||||
}
|
||||
p, err := encodeV2(map[string][]byte{"pets/nested/file": []byte("bad")}, Metadata{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Restore(sealPlain(t, magicV2, p), home, testKey); err == nil {
|
||||
t.Fatal("restore followed symlink")
|
||||
}
|
||||
})
|
||||
}
|
||||
home := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(home, "pets"), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l, err := net.Listen("unix", filepath.Join(home, "pets/socket"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
if err := Create(home, filepath.Join(t.TempDir(), "archive"), testKey); err == nil {
|
||||
t.Fatal("special file accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestV2LimitsAndCorruption(t *testing.T) {
|
||||
files := make(map[string][]byte)
|
||||
home := t.TempDir()
|
||||
for i := 0; i < MaxFiles; i++ {
|
||||
name := fmt.Sprintf("pets/%d", i)
|
||||
files[name] = nil
|
||||
put(t, filepath.Join(home, name), nil)
|
||||
}
|
||||
archive := filepath.Join(t.TempDir(), "archive")
|
||||
if err := Create(home, archive, testKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Restore(archive, t.TempDir(), testKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
put(t, filepath.Join(home, "pets/extra"), nil)
|
||||
if err := Create(home, archive, testKey); err == nil {
|
||||
t.Fatal("file count accepted")
|
||||
}
|
||||
files["pets/extra"] = nil
|
||||
p, err := encodeV2(files, Metadata{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := decodeV2(p); err == nil {
|
||||
t.Fatal("archive file count accepted")
|
||||
}
|
||||
p, err = encodeV2(map[string][]byte{"pets/large": make([]byte, MaxTotalBytes+1)}, Metadata{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := decodeV2(p); err == nil {
|
||||
t.Fatal("expanded file limit accepted")
|
||||
}
|
||||
p, err = encodeV2(nil, Metadata{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
badChecksum := bytes.Clone(p)
|
||||
badChecksum[len(badChecksum)-8] ^= 1
|
||||
for _, bad := range [][]byte{p[:len(p)-1], append(bytes.Clone(p), 0), append(bytes.Clone(p), p...), badChecksum} {
|
||||
if _, err := decodeV2(bad); err == nil {
|
||||
t.Fatal("corrupt gzip accepted")
|
||||
}
|
||||
}
|
||||
if _, err := encodeV2(nil, Metadata{DSHVersion: string(make([]byte, maxManifestBytes))}); err == nil {
|
||||
t.Fatal("oversized manifest accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
home, dest := t.TempDir(), t.TempDir()
|
||||
for _, name := range allowed {
|
||||
put(t, filepath.Join(home, name), []byte("secret:"+name))
|
||||
}
|
||||
put(t, filepath.Join(home, "not-allowed"), []byte("ignore"))
|
||||
archive := filepath.Join(t.TempDir(), "archive")
|
||||
if err := Create(home, archive, testKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := os.ReadFile(archive)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Contains(first, []byte("secret:")) {
|
||||
t.Fatal("plaintext leaked")
|
||||
}
|
||||
if err := Create(home, archive, testKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := os.ReadFile(archive)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Equal(first, second) {
|
||||
t.Fatal("nonce reused")
|
||||
}
|
||||
put(t, filepath.Join(dest, allowed[0]), []byte("old"))
|
||||
if err := os.Chmod(filepath.Join(dest, allowed[0]), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Restore(archive, dest, testKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range allowed {
|
||||
data, err := os.ReadFile(filepath.Join(dest, name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != "secret:"+name {
|
||||
t.Fatalf("wrong data: %s", name)
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(dest, name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != 0600 {
|
||||
t.Fatalf("mode: %v", info.Mode())
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dest, "not-allowed")); !os.IsNotExist(err) {
|
||||
t.Fatal("unlisted file restored")
|
||||
}
|
||||
info, err := os.Stat(archive)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != 0600 {
|
||||
t.Fatal("archive permissions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthentication(t *testing.T) {
|
||||
archive := sealed(t, payload{1, map[string][]byte{allowed[0]: []byte("new")}})
|
||||
original, err := os.ReadFile(archive)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, offset := range []int{0, len(magic), len(original) - 1} {
|
||||
t.Run(fmt.Sprint(offset), func(t *testing.T) {
|
||||
data := bytes.Clone(original)
|
||||
data[offset] ^= 1
|
||||
put(t, archive, data)
|
||||
home := t.TempDir()
|
||||
put(t, filepath.Join(home, allowed[0]), []byte("old"))
|
||||
if err := Restore(archive, home, testKey); err == nil {
|
||||
t.Fatal("tampering accepted")
|
||||
}
|
||||
got, _ := os.ReadFile(filepath.Join(home, allowed[0]))
|
||||
if string(got) != "old" {
|
||||
t.Fatal("modified on failure")
|
||||
}
|
||||
})
|
||||
}
|
||||
put(t, archive, original)
|
||||
if err := Restore(archive, t.TempDir(), bytes.Repeat([]byte{1}, 32)); err == nil {
|
||||
t.Fatal("wrong key accepted")
|
||||
}
|
||||
put(t, archive, original[:len(original)-1])
|
||||
if err := Restore(archive, t.TempDir(), testKey); err == nil {
|
||||
t.Fatal("truncation accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidPathsAndVersion(t *testing.T) {
|
||||
for _, name := range []string{"../escape", "/tmp/escape", "profiles/web/../../../escape", "profiles//web/package.json", "other", "profiles\\web\\package.json"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
put(t, filepath.Join(home, allowed[0]), []byte("old"))
|
||||
archive := sealed(t, payload{1, map[string][]byte{allowed[0]: []byte("new"), name: []byte("bad")}})
|
||||
if err := Restore(archive, home, testKey); err == nil {
|
||||
t.Fatal("invalid path accepted")
|
||||
}
|
||||
got, _ := os.ReadFile(filepath.Join(home, allowed[0]))
|
||||
if string(got) != "old" {
|
||||
t.Fatal("partial restore before validation")
|
||||
}
|
||||
})
|
||||
}
|
||||
if err := Restore(sealed(t, payload{2, map[string][]byte{}}), t.TempDir(), testKey); err == nil {
|
||||
t.Fatal("version accepted")
|
||||
}
|
||||
files := map[string][]byte{}
|
||||
for i := 0; i < MaxFiles+1; i++ {
|
||||
files[fmt.Sprint(i)] = nil
|
||||
}
|
||||
if err := Restore(sealed(t, payload{1, files}), t.TempDir(), testKey); err == nil {
|
||||
t.Fatal("file count accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSymlinks(t *testing.T) {
|
||||
for _, outside := range []bool{false, true} {
|
||||
for _, parent := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("outside=%v/parent=%v", outside, parent), func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
target := filepath.Join(home, "target")
|
||||
linkTarget := "target"
|
||||
if outside {
|
||||
target = filepath.Join(t.TempDir(), "target")
|
||||
linkTarget = target
|
||||
}
|
||||
name := allowed[0]
|
||||
link := filepath.Join(home, name)
|
||||
if parent {
|
||||
name = "profiles/web/package.json"
|
||||
link = filepath.Join(home, "profiles")
|
||||
put(t, filepath.Join(target, "web/package.json"), []byte("safe"))
|
||||
} else {
|
||||
put(t, target, []byte("safe"))
|
||||
}
|
||||
if err := os.Symlink(linkTarget, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Create(home, filepath.Join(t.TempDir(), "archive"), testKey); err == nil {
|
||||
t.Fatal("create followed symlink")
|
||||
}
|
||||
archive := sealed(t, payload{1, map[string][]byte{name: []byte("bad")}})
|
||||
if err := Restore(archive, home, testKey); err == nil {
|
||||
t.Fatal("restore followed symlink")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSymlinkPreflightAndArchive(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
put(t, filepath.Join(home, allowed[0]), []byte("old"))
|
||||
target := filepath.Join(t.TempDir(), "target")
|
||||
put(t, target, []byte("safe"))
|
||||
if err := os.Symlink(target, filepath.Join(home, "dsh-ssh.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
archive := sealed(t, payload{1, map[string][]byte{allowed[0]: []byte("new"), "dsh-ssh.json": []byte("bad")}})
|
||||
if err := Restore(archive, home, testKey); err == nil {
|
||||
t.Fatal("symlink accepted")
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(home, allowed[0]))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "old" {
|
||||
t.Fatal("preflight modified earlier file")
|
||||
}
|
||||
got, err = os.ReadFile(target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "safe" {
|
||||
t.Fatal("outside target modified")
|
||||
}
|
||||
link := filepath.Join(t.TempDir(), "archive-link")
|
||||
if err := os.Symlink(archive, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Restore(link, t.TempDir(), testKey); err == nil {
|
||||
t.Fatal("archive symlink accepted")
|
||||
}
|
||||
if err := Create(t.TempDir(), link, testKey); err == nil {
|
||||
t.Fatal("destination symlink accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSizeLimits(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
name := filepath.Join(home, allowed[0])
|
||||
put(t, name, nil)
|
||||
if err := os.Truncate(name, MaxTotalBytes+1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
archive := filepath.Join(t.TempDir(), "archive")
|
||||
if err := Create(home, archive, testKey); err == nil {
|
||||
t.Fatal("oversized file accepted")
|
||||
}
|
||||
if err := os.Truncate(name, MaxTotalBytes); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Create(home, archive, testKey); err != nil {
|
||||
t.Fatal("exact limit:", err)
|
||||
}
|
||||
if err := Restore(archive, t.TempDir(), testKey); err != nil {
|
||||
t.Fatal("exact limit restore:", err)
|
||||
}
|
||||
put(t, filepath.Join(home, allowed[1]), []byte{1})
|
||||
if err := Create(home, archive, testKey); err == nil {
|
||||
t.Fatal("aggregate limit accepted")
|
||||
}
|
||||
archive = sealed(t, payload{1, map[string][]byte{allowed[0]: make([]byte, MaxTotalBytes), allowed[1]: {1}}})
|
||||
if err := Restore(archive, t.TempDir(), testKey); err == nil {
|
||||
t.Fatal("restore aggregate limit accepted")
|
||||
}
|
||||
put(t, archive, nil)
|
||||
if err := os.Truncate(archive, maxArchiveBytes+1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Restore(archive, t.TempDir(), testKey); err == nil {
|
||||
t.Fatal("oversized archive accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingFilesAndInvalidKey(t *testing.T) {
|
||||
archive := filepath.Join(t.TempDir(), "archive")
|
||||
if err := Create(t.TempDir(), archive, nil); err == nil {
|
||||
t.Fatal("invalid key accepted")
|
||||
}
|
||||
if err := Create(t.TempDir(), archive, testKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
home := t.TempDir()
|
||||
put(t, filepath.Join(home, allowed[0]), []byte("keep"))
|
||||
if err := Restore(archive, home, testKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := os.ReadFile(filepath.Join(home, allowed[0]))
|
||||
if string(got) != "keep" {
|
||||
t.Fatal("missing file removed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
const passwordMagic = "DSHPASS\x01"
|
||||
const MaxEncryptedBytes int64 = int64(maxArchiveBytes + len(passwordMagic) + 16)
|
||||
|
||||
// The format fixes KDF costs; untrusted archives cannot request arbitrary memory.
|
||||
func passwordKey(password string, salt []byte) ([]byte, error) {
|
||||
if len(password) == 0 || len(password) > 1024 {
|
||||
return nil, errors.New("backup password required (maximum 1024 bytes)")
|
||||
}
|
||||
return argon2.IDKey([]byte(password), salt, 3, 64*1024, 2, 32), nil
|
||||
}
|
||||
|
||||
func CreateWithPassword(home, destination, password string, metadata Metadata) error {
|
||||
salt := make([]byte, 16)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return err
|
||||
}
|
||||
key, err := passwordKey(password, salt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer clear(key)
|
||||
dir, err := os.MkdirTemp(filepath.Dir(destination), ".password-backup-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
inner := filepath.Join(dir, "archive")
|
||||
if err = CreateWithMetadata(home, inner, key, metadata); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := os.ReadFile(inner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = append(append([]byte(passwordMagic), salt...), data...)
|
||||
root, err := os.OpenRoot(filepath.Dir(destination))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer root.Close()
|
||||
return replace(root, filepath.Base(destination), data)
|
||||
}
|
||||
|
||||
// Prepare authenticates and validates into a private staging home before the caller
|
||||
// stops a container or snapshots its live home. Only ciphertext is retained.
|
||||
func Prepare(archive, password string, legacyKey []byte) (restore func(string) error, cleanup func(), err error) {
|
||||
f, err := os.OpenFile(archive, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
data, err := readBounded(f, MaxEncryptedBytes)
|
||||
f.Close()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
key := append([]byte(nil), legacyKey...)
|
||||
if bytes.HasPrefix(data, []byte(passwordMagic)) {
|
||||
if len(data) < len(passwordMagic)+16 {
|
||||
return nil, nil, errors.New("truncated backup")
|
||||
}
|
||||
key, err = passwordKey(password, data[len(passwordMagic):len(passwordMagic)+16])
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
data = data[len(passwordMagic)+16:]
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "dsh-backup-validate-")
|
||||
if err != nil {
|
||||
clear(key)
|
||||
return nil, nil, err
|
||||
}
|
||||
cleanup = func() { clear(key); os.RemoveAll(dir) }
|
||||
inner := filepath.Join(dir, "archive")
|
||||
if err = os.WriteFile(inner, data, 0600); err == nil {
|
||||
err = os.Mkdir(filepath.Join(dir, "home"), 0700)
|
||||
}
|
||||
if err == nil {
|
||||
err = Restore(inner, filepath.Join(dir, "home"), key)
|
||||
}
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return nil, nil, err
|
||||
}
|
||||
if err = os.RemoveAll(filepath.Join(dir, "home")); err != nil {
|
||||
cleanup()
|
||||
return nil, nil, err
|
||||
}
|
||||
return func(home string) error { return Restore(inner, home, key) }, cleanup, nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPasswordArchive(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(home, ".credentials.yaml"), []byte("secret"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, second := filepath.Join(dir, "first"), filepath.Join(dir, "second")
|
||||
for _, path := range []string{first, second} {
|
||||
if err := CreateWithPassword(home, path, "old-password", Metadata{DSHVersion: "test"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
a, _ := os.ReadFile(first)
|
||||
b, _ := os.ReadFile(second)
|
||||
if bytes.Equal(a[:len(passwordMagic)+16], b[:len(passwordMagic)+16]) {
|
||||
t.Fatal("reused salt")
|
||||
}
|
||||
if bytes.Contains(a, []byte("secret")) {
|
||||
t.Fatal("plaintext")
|
||||
}
|
||||
if _, _, err := Prepare(first, "wrong-password", nil); err == nil {
|
||||
t.Fatal("accepted wrong password")
|
||||
}
|
||||
restore, cleanup, err := Prepare(first, "old-password", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cleanup()
|
||||
destination := t.TempDir()
|
||||
if err := restore(destination); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restored, _ := os.ReadFile(filepath.Join(destination, ".credentials.yaml"))
|
||||
if string(restored) != "secret" {
|
||||
t.Fatal(string(restored))
|
||||
}
|
||||
a[len(passwordMagic)] ^= 1
|
||||
os.WriteFile(second, a, 0600)
|
||||
if _, _, err := Prepare(second, "old-password", nil); err == nil {
|
||||
t.Fatal("accepted altered salt")
|
||||
}
|
||||
legacy := filepath.Join(dir, "legacy")
|
||||
key := bytes.Repeat([]byte{9}, 32)
|
||||
if err := Create(home, legacy, key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restore, legacyCleanup, err := Prepare(legacy, "", key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer legacyCleanup()
|
||||
if err := restore(t.TempDir()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.WriteFile(second, []byte(passwordMagic), 0600)
|
||||
if _, _, err := Prepare(second, "old-password", nil); err == nil {
|
||||
t.Fatal("accepted truncated header")
|
||||
}
|
||||
f, err := os.Create(second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Truncate(MaxEncryptedBytes + 1)
|
||||
f.Close()
|
||||
if _, _, err := Prepare(second, "old-password", nil); err == nil {
|
||||
t.Fatal("accepted oversized file")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user