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