]> git.giacomolli.uk - onechat/blob - backup.go
Describe queued event delivery boundaries
[onechat] / backup.go
1 package main
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "io/fs"
10 "net"
11 "os"
12 "path/filepath"
13 "strings"
14 )
15
16 // backup asks the daemon for a private archive and prints its path and size.
17 func backup(ctx context.Context, path string, out io.Writer) (err error) {
18 dir, err := dataDir()
19 if err != nil {
20 return fmt.Errorf("find data directory: %w", err)
21 }
22
23 path, err = filepath.Abs(path)
24 if err != nil {
25 return fmt.Errorf("find backup path: %w", err)
26 }
27
28 var dialer net.Dialer
29
30 conn, err := dialer.DialContext(ctx, "unix", filepath.Join(dir, socketFileName))
31 if err != nil {
32 return errors.New("backup needs a running daemon")
33 }
34
35 defer func() {
36 err = errors.Join(err, conn.Close())
37 }()
38
39 stop := context.AfterFunc(ctx, func() {
40 // Cancellation closes the socket only to interrupt pending I/O.
41 conn.Close() // nolint: errcheck
42 })
43
44 defer stop()
45
46 err = json.NewEncoder(conn).Encode(SocketRequest{
47 Type: "backup",
48 Text: path,
49 })
50 if err != nil {
51 return fmt.Errorf("send backup request: %w", err)
52 }
53
54 decoder := json.NewDecoder(conn)
55 for {
56 var event Event
57
58 err = decoder.Decode(&event)
59 if err != nil {
60 return fmt.Errorf("read backup answer: %w", err)
61 }
62
63 switch event.Kind {
64 case "backup":
65 _, err = fmt.Fprintln(out, event.Text)
66 if err != nil {
67 return fmt.Errorf("write backup answer: %w", err)
68 }
69
70 return nil
71 case "error":
72 return fmt.Errorf("backup failed: %s", event.Text)
73 }
74 }
75 }
76
77 // restore runs the offline restore command and reports its data directory.
78 func restore(ctx context.Context, path string, out io.Writer) error {
79 dir, err := dataDir()
80 if err != nil {
81 return fmt.Errorf("find restore directory: %w", err)
82 }
83
84 err = backupRestore(ctx, path, dir)
85 if err != nil {
86 return fmt.Errorf("restore archive: %w", err)
87 }
88
89 _, err = fmt.Fprintln(out, "Restored", dir)
90 if err != nil {
91 return fmt.Errorf("write restore answer: %w", err)
92 }
93
94 return nil
95 }
96
97 // backupRestore installs a validated archive into an empty data directory while
98 // holding the token lock. It keeps the lock file so daemon starts use that same
99 // inode, including after a failed restore.
100 func backupRestore(ctx context.Context, path, dir string) (err error) {
101 dir, err = filepath.Abs(dir)
102 if err != nil {
103 return fmt.Errorf("find restore destination: %w", err)
104 }
105
106 entries, err := os.ReadDir(dir)
107 if err != nil {
108 return fmt.Errorf("read restore destination: %w", err)
109 }
110
111 if len(entries) != 0 {
112 hasLock := false
113 for _, entry := range entries {
114 hasLock = hasLock || entry.Name() == tokenLockFileName
115 }
116
117 if !hasLock {
118 return errors.New("restore needs an empty data directory")
119 }
120 }
121
122 tokens, err := lockTokens(dir)
123 if err != nil {
124 return fmt.Errorf("restore needs a stopped daemon: %w", err)
125 }
126
127 defer func() {
128 err = errors.Join(err, tokens.Close())
129 }()
130
131 if len(entries) != 0 {
132 return errors.New("restore needs an empty data directory")
133 }
134
135 entries, err = os.ReadDir(dir)
136 if err != nil {
137 return fmt.Errorf("check restore destination: %w", err)
138 }
139
140 if len(entries) != 1 || entries[0].Name() != tokenLockFileName {
141 return errors.New("restore needs an empty data directory")
142 }
143
144 stage, err := os.MkdirTemp(filepath.Dir(dir), ".onechat-restore-*")
145 if err != nil {
146 return fmt.Errorf("create restore directory: %w", err)
147 }
148
149 defer func() {
150 err = errors.Join(err, os.RemoveAll(stage))
151 }()
152
153 err = backupExtract(ctx, path, stage)
154 if err != nil {
155 return fmt.Errorf("extract backup: %w", err)
156 }
157
158 entries, err = os.ReadDir(stage)
159 if err != nil {
160 return fmt.Errorf("read restored files: %w", err)
161 }
162
163 for _, entry := range entries {
164 err = os.Rename(filepath.Join(stage, entry.Name()), filepath.Join(dir, entry.Name()))
165 if err != nil {
166 return fmt.Errorf("install restored entry: %w", err)
167 }
168 }
169
170 return nil
171 }
172
173 // backupData stages a session snapshot, then copies the data under snapshot's
174 // write locks. Compression runs after the locks are released.
175 func backupData(ctx context.Context, dir, path string, whatsapp *WhatsApp, snapshot func(context.Context, func() error) error) (size int64, err error) {
176 if !filepath.IsAbs(path) {
177 return 0, errors.New("backup path must be absolute")
178 }
179
180 stage, err := os.MkdirTemp("", "onechat-backup-*")
181 if err != nil {
182 return 0, fmt.Errorf("create backup directory: %w", err)
183 }
184
185 defer func() {
186 err = errors.Join(err, os.RemoveAll(stage))
187 }()
188
189 err = whatsapp.BackupSession(ctx, filepath.Join(stage, whatsappDirName, whatsappSessionFileName))
190 if err != nil {
191 return 0, fmt.Errorf("copy WhatsApp session: %w", err)
192 }
193
194 err = snapshot(ctx, func() error {
195 return backupCopy(ctx, dir, stage, path)
196 })
197 if err != nil {
198 return 0, fmt.Errorf("copy data directory: %w", err)
199 }
200
201 size, err = backupArchive(ctx, stage, path)
202 if err != nil {
203 return 0, fmt.Errorf("archive backup: %w", err)
204 }
205
206 return size, nil
207 }
208
209 // backupCopy stages regular files and directories, excluding runtime files, the
210 // output archive, and raw session database files. Each file keeps its mode.
211 func backupCopy(ctx context.Context, source, target, output string) error {
212 var directories []string
213
214 modes := make(map[string]fs.FileMode)
215
216 err := filepath.WalkDir(source, func(path string, entry fs.DirEntry, err error) error {
217 if err != nil {
218 return fmt.Errorf("read data entry: %w", err)
219 }
220
221 name, err := filepath.Rel(source, path)
222 if err != nil {
223 return fmt.Errorf("name data entry: %w", err)
224 }
225
226 if name == "." {
227 return nil
228 }
229
230 absolute, err := filepath.Abs(path)
231 if err != nil {
232 return fmt.Errorf("find data entry: %w", err)
233 }
234
235 session := filepath.Join(whatsappDirName, whatsappSessionFileName)
236 if backupExcluded(name) || absolute == output || name == session || strings.HasPrefix(name, session+"-") {
237 if entry.IsDir() {
238 return filepath.SkipDir
239 }
240
241 return nil
242 }
243
244 info, err := entry.Info()
245 if err != nil {
246 return fmt.Errorf("read data file: %w", err)
247 }
248
249 destination := filepath.Join(target, name)
250 switch {
251 case info.IsDir():
252 err = os.MkdirAll(destination, 0o700)
253 directories = append(directories, destination)
254 modes[destination] = info.Mode().Perm()
255 case info.Mode().IsRegular():
256 err = backupStageFile(ctx, path, destination)
257 default:
258 return fmt.Errorf("unsupported data entry %q", name)
259 }
260
261 if err != nil {
262 return fmt.Errorf("stage data entry %q: %w", name, err)
263 }
264
265 return nil
266 })
267 if err != nil {
268 return fmt.Errorf("stage backup: %w", err)
269 }
270
271 for i := len(directories) - 1; i >= 0; i-- {
272 path := directories[i]
273 err = os.Chmod(path, modes[path])
274 if err != nil {
275 return fmt.Errorf("stage directory mode: %w", err)
276 }
277 }
278
279 return nil
280 }
281
282 // backupStageFile opens before it reads size and mode, so an atomic state file
283 // replacement cannot join the metadata of one version to the bytes of another.
284 func backupStageFile(ctx context.Context, source, target string) (err error) {
285 input, err := os.Open(source)
286 if err != nil {
287 return fmt.Errorf("open snapshot source: %w", err)
288 }
289
290 defer func() {
291 err = errors.Join(err, input.Close())
292 }()
293
294 info, err := input.Stat()
295 if err != nil {
296 return fmt.Errorf("read snapshot source: %w", err)
297 }
298
299 if !info.Mode().IsRegular() {
300 return errors.New("snapshot source must be a regular file")
301 }
302
303 file, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
304 if err != nil {
305 return fmt.Errorf("create staged file: %w", err)
306 }
307
308 defer func() {
309 err = errors.Join(err, file.Close())
310 }()
311
312 _, err = io.CopyN(file, BackupReader{
313 ctx: ctx,
314 reader: input,
315 }, info.Size())
316 if err != nil {
317 return fmt.Errorf("stage file: %w", err)
318 }
319
320 err = file.Chmod(info.Mode().Perm())
321 if err != nil {
322 return fmt.Errorf("stage file mode: %w", err)
323 }
324
325 return nil
326 }