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