]> git.giacomolli.uk - onechat/blob - backup_archive.go
Import WhatsApp history into a child
[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" {
274                 return true
275         }
276
277         return false
278 }