]> git.giacomolli.uk - onechat/blob - backup_archive_test.go
Write private data archives with safe extraction
[onechat] / backup_archive_test.go
1 package main
2
3 import (
4 "archive/tar"
5 "compress/gzip"
6 "os"
7 "path/filepath"
8 "testing"
9
10 "github.com/stretchr/testify/require"
11 )
12
13 func TestBackupArchiveRestoresBytesAndModes(t *testing.T) {
14 root := t.TempDir()
15 require.NoError(t, os.Mkdir(filepath.Join(root, "agent"), 0o750))
16 require.NoError(t, os.WriteFile(filepath.Join(root, "agent", "instructions.md"), []byte("Keep the whole chat.\n"), 0o640))
17
18 archive := filepath.Join(t.TempDir(), "backup.tar.gz")
19 size, err := backupArchive(t.Context(), root, archive)
20 require.NoError(t, err)
21
22 info, err := os.Stat(archive)
23 require.NoError(t, err)
24 require.Equal(t, size, info.Size())
25 require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
26
27 restored := t.TempDir()
28 require.NoError(t, backupExtract(t.Context(), archive, restored))
29
30 data, err := os.ReadFile(filepath.Join(restored, "agent", "instructions.md"))
31 require.NoError(t, err)
32 require.Equal(t, "Keep the whole chat.\n", string(data))
33
34 info, err = os.Stat(filepath.Join(restored, "agent"))
35 require.NoError(t, err)
36 require.Equal(t, os.FileMode(0o750), info.Mode().Perm())
37
38 info, err = os.Stat(filepath.Join(restored, "agent", "instructions.md"))
39 require.NoError(t, err)
40 require.Equal(t, os.FileMode(0o640), info.Mode().Perm())
41 }
42
43 func TestRestoreRejectsUnsafeArchiveEntries(t *testing.T) {
44 testCases := []struct {
45 name string
46 header tar.Header
47 }{
48 {
49 name: "absolute paths fail",
50 header: tar.Header{
51 Name: "/escape",
52 Typeflag: tar.TypeReg,
53 Mode: 0o600,
54 },
55 },
56 {
57 name: "parent paths fail",
58 header: tar.Header{
59 Name: "../escape",
60 Typeflag: tar.TypeReg,
61 Mode: 0o600,
62 },
63 },
64 {
65 name: "embedded parent paths fail",
66 header: tar.Header{
67 Name: "agent/../../escape",
68 Typeflag: tar.TypeReg,
69 Mode: 0o600,
70 },
71 },
72 {
73 name: "symbolic links fail",
74 header: tar.Header{
75 Name: "agent",
76 Typeflag: tar.TypeSymlink,
77 Linkname: "../escape",
78 Mode: 0o777,
79 },
80 },
81 {
82 name: "hard links fail",
83 header: tar.Header{
84 Name: "agent",
85 Typeflag: tar.TypeLink,
86 Linkname: "/escape",
87 Mode: 0o600,
88 },
89 },
90 {
91 name: "device files fail",
92 header: tar.Header{
93 Name: "device",
94 Typeflag: tar.TypeChar,
95 Mode: 0o600,
96 },
97 },
98 {
99 name: "setuid modes fail",
100 header: tar.Header{
101 Name: "agent",
102 Typeflag: tar.TypeReg,
103 Mode: 0o4600,
104 },
105 },
106 {
107 name: "lock replacement fails",
108 header: tar.Header{
109 Name: "auth.lock",
110 Typeflag: tar.TypeReg,
111 Mode: 0o600,
112 },
113 },
114 }
115 for _, tc := range testCases {
116 t.Run(tc.name, func(t *testing.T) {
117 archive := backupTestArchive(t, tc.header)
118 require.Error(t, backupExtract(t.Context(), archive, t.TempDir()))
119 })
120 }
121 }
122
123 func TestBackupFailureKeepsTheDestination(t *testing.T) {
124 root := t.TempDir()
125 require.NoError(t, os.Symlink("missing", filepath.Join(root, "link")))
126
127 archive := filepath.Join(t.TempDir(), "backup.tar.gz")
128 require.NoError(t, os.WriteFile(archive, []byte("keep"), 0o600))
129
130 _, err := backupArchive(t.Context(), root, archive)
131 require.ErrorContains(t, err, "unsupported backup entry")
132
133 data, err := os.ReadFile(archive)
134 require.NoError(t, err)
135 require.Equal(t, "keep", string(data))
136
137 entries, err := os.ReadDir(filepath.Dir(archive))
138 require.NoError(t, err)
139 require.Len(t, entries, 1)
140 }
141
142 func backupTestArchive(t *testing.T, headers ...tar.Header) string {
143 t.Helper()
144
145 path := filepath.Join(t.TempDir(), "input.tar.gz")
146 file, err := os.Create(path)
147 require.NoError(t, err)
148
149 compressed := gzip.NewWriter(file)
150
151 archive := tar.NewWriter(compressed)
152 for _, header := range headers {
153 require.NoError(t, archive.WriteHeader(&header))
154 }
155
156 require.NoError(t, archive.Close())
157 require.NoError(t, compressed.Close())
158 require.NoError(t, file.Close())
159
160 return path
161 }