]> git.giacomolli.uk - onechat/blob - backup_archive.go
Return known failures without redundant checks
[onechat] / backup_archive.go
1 package main
2
3 import (
4 "archive/tar"
5 "compress/gzip"
6 "context"
7 "errors"
8 "fmt"
9 "io"
10 "io/fs"
11 "os"
12 "path/filepath"
13 "strings"
14 )
15
16 // BackupReader checks cancellation between reads of an archive or file.
17 type BackupReader struct {
18 ctx context.Context
19 reader io.Reader
20 }
21
22 func (r BackupReader) Read(data []byte) (int, error) {
23 if r.ctx.Err() != nil {
24 return 0, r.ctx.Err()
25 }
26
27 count, err := r.reader.Read(data)
28 if err != nil {
29 return count, fmt.Errorf("read backup data: %w", err)
30 }
31
32 return count, nil
33 }
34
35 // backupArchive writes the files in root to path through a private temporary
36 // file and a rename. Only directories and regular files enter the archive.
37 func backupArchive(ctx context.Context, root, path string) (size int64, err error) {
38 file, err := os.CreateTemp(filepath.Dir(path), ".onechat-backup-*")
39 if err != nil {
40 return 0, fmt.Errorf("create archive: %w", err)
41 }
42
43 defer func() {
44 err = errors.Join(err, file.Close())
45 removeErr := os.Remove(file.Name())
46 if !errors.Is(removeErr, os.ErrNotExist) {
47 err = errors.Join(err, removeErr)
48 }
49 }()
50
51 compressed := gzip.NewWriter(file)
52 archive := tar.NewWriter(compressed)
53 err = filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
54 if err != nil {
55 return fmt.Errorf("walk backup: %w", err)
56 }
57
58 if ctx.Err() != nil {
59 return ctx.Err()
60 }
61
62 if path == root {
63 return nil
64 }
65
66 name, err := filepath.Rel(root, path)
67 if err != nil {
68 return fmt.Errorf("name archive entry: %w", err)
69 }
70
71 info, err := entry.Info()
72 if err != nil {
73 return fmt.Errorf("read backup entry: %w", err)
74 }
75
76 if !info.IsDir() && !info.Mode().IsRegular() {
77 return fmt.Errorf("unsupported backup entry %q", name)
78 }
79
80 header, err := tar.FileInfoHeader(info, "")
81 if err != nil {
82 return fmt.Errorf("make archive header: %w", err)
83 }
84
85 header.Name = filepath.ToSlash(name)
86 header.Uid, header.Gid = 0, 0
87 header.Uname, header.Gname = "", ""
88 err = archive.WriteHeader(header)
89 if err != nil {
90 return fmt.Errorf("write archive header: %w", err)
91 }
92
93 if info.IsDir() {
94 return nil
95 }
96
97 err = backupCopyFile(ctx, archive, path, info.Size())
98 if err != nil {
99 return fmt.Errorf("archive file: %w", err)
100 }
101
102 return nil
103 })
104
105 err = errors.Join(err, archive.Close(), compressed.Close())
106 if err != nil {
107 return 0, fmt.Errorf("write archive: %w", err)
108 }
109
110 err = file.Sync()
111 if err != nil {
112 return 0, fmt.Errorf("sync archive: %w", err)
113 }
114
115 info, err := file.Stat()
116 if err != nil {
117 return 0, fmt.Errorf("read archive size: %w", err)
118 }
119
120 err = os.Rename(file.Name(), path)
121 if err != nil {
122 return 0, fmt.Errorf("install archive: %w", err)
123 }
124
125 return info.Size(), nil
126 }
127
128 // backupCopyFile copies the size fixed at the snapshot of path. Growth after
129 // that point is not part of the copy, and a shorter file is an error.
130 func backupCopyFile(ctx context.Context, out io.Writer, path string, size int64) (err error) {
131 file, err := os.Open(path)
132 if err != nil {
133 return fmt.Errorf("open backup file: %w", err)
134 }
135
136 defer func() {
137 err = errors.Join(err, file.Close())
138 }()
139
140 _, err = io.CopyN(out, BackupReader{
141 ctx: ctx,
142 reader: file,
143 }, size)
144 if err != nil {
145 return fmt.Errorf("copy backup file: %w", err)
146 }
147
148 return nil
149 }
150
151 // backupExtract restores regular files and directories in an empty staging
152 // directory. Links, duplicate entries, unsafe names, and special modes fail.
153 // Directory modes apply last so a read-only directory can hold files.
154 func backupExtract(ctx context.Context, path, root string) (err error) {
155 file, err := os.Open(path)
156 if err != nil {
157 return fmt.Errorf("open archive: %w", err)
158 }
159
160 defer func() {
161 err = errors.Join(err, file.Close())
162 }()
163
164 compressed, err := gzip.NewReader(file)
165 if err != nil {
166 return fmt.Errorf("open compressed archive: %w", err)
167 }
168
169 defer func() {
170 err = errors.Join(err, compressed.Close())
171 }()
172
173 archive := tar.NewReader(BackupReader{
174 ctx: ctx,
175 reader: compressed,
176 })
177
178 seen := make(map[string]bool)
179
180 var directories []*tar.Header
181
182 for {
183 var header *tar.Header
184
185 header, err = archive.Next()
186 if errors.Is(err, io.EOF) {
187 break
188 }
189
190 if err != nil {
191 return fmt.Errorf("read archive entry: %w", err)
192 }
193
194 name := strings.TrimSuffix(header.Name, "/")
195 if !filepath.IsLocal(name) || strings.Contains(name, "\\") || filepath.Clean(name) != name || backupExcluded(name) {
196 return fmt.Errorf("unsafe archive path %q", header.Name)
197 }
198
199 if seen[name] {
200 return fmt.Errorf("duplicate archive path %q", name)
201 }
202
203 seen[name] = true
204 if header.Mode & ^int64(0o777) != 0 {
205 return fmt.Errorf("unsafe archive mode for %q", name)
206 }
207
208 target := filepath.Join(root, name)
209 switch header.Typeflag {
210 case tar.TypeDir:
211 err = os.MkdirAll(target, 0o700)
212 directories = append(directories, header)
213 case tar.TypeReg:
214 err = os.MkdirAll(filepath.Dir(target), 0o700)
215 if err == nil {
216 err = backupExtractFile(archive, target, os.FileMode(header.Mode))
217 }
218 default:
219 return fmt.Errorf("unsupported archive entry %q", name)
220 }
221
222 if err != nil {
223 return fmt.Errorf("restore archive entry %q: %w", name, err)
224 }
225 }
226
227 // Read through the gzip trailer to check its checksum.
228 _, err = io.Copy(io.Discard, compressed)
229 if err != nil {
230 return fmt.Errorf("check archive checksum: %w", err)
231 }
232
233 for i := len(directories) - 1; i >= 0; i-- {
234 header := directories[i]
235 err = os.Chmod(filepath.Join(root, header.Name), os.FileMode(header.Mode))
236 if err != nil {
237 return fmt.Errorf("restore directory mode: %w", err)
238 }
239 }
240
241 return nil
242 }
243
244 // backupExcluded keeps runtime locks, sockets, probe files, and import copies
245 // out of the snapshot.
246 func backupExcluded(name string) bool {
247 first, _, _ := strings.Cut(filepath.ToSlash(name), "/")
248 if first == socketFileName || first == tokenLockFileName || first == "probe" || strings.HasPrefix(first, "agent-backup-") {
249 return true
250 }
251
252 if filepath.Base(name) == "lock" || (first == imageDirName && strings.HasPrefix(filepath.Base(name), ".image-")) || (first == voiceDirName && (strings.HasPrefix(filepath.Base(name), ".voice-") || strings.HasPrefix(filepath.Base(name), ".transcript-"))) {
253 return true
254 }
255
256 return false
257 }
258
259 func backupExtractFile(in io.Reader, path string, mode os.FileMode) (err error) {
260 file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
261 if err != nil {
262 return fmt.Errorf("create restored file: %w", err)
263 }
264
265 defer func() {
266 err = errors.Join(err, file.Close())
267 }()
268
269 _, err = io.Copy(file, in)
270 if err != nil {
271 return fmt.Errorf("write restored file: %w", err)
272 }
273
274 err = file.Chmod(mode)
275 if err != nil {
276 return fmt.Errorf("restore file mode: %w", err)
277 }
278
279 return nil
280 }