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) {
20 return fmt.Errorf("find data directory: %w", err)
23 path, err = filepath.Abs(path)
25 return fmt.Errorf("find backup path: %w", err)
30 conn, err := dialer.DialContext(ctx, "unix", filepath.Join(dir, socketFileName))
32 return errors.New("backup needs a running daemon")
36 err = errors.Join(err, conn.Close())
39 stop := context.AfterFunc(ctx, func() {
40 // Cancellation closes the socket only to interrupt pending I/O.
41 conn.Close() // nolint: errcheck
46 err = json.NewEncoder(conn).Encode(SocketRequest{
51 return fmt.Errorf("send backup request: %w", err)
54 decoder := json.NewDecoder(conn)
58 err = decoder.Decode(&event)
60 return fmt.Errorf("read backup answer: %w", err)
65 _, err = fmt.Fprintln(out, event.Text)
67 return fmt.Errorf("write backup answer: %w", err)
72 return fmt.Errorf("backup failed: %s", event.Text)
77 // restore runs the offline restore command and reports its data directory.
78 func restore(ctx context.Context, path string, out io.Writer) error {
81 return fmt.Errorf("find restore directory: %w", err)
84 err = backupRestore(ctx, path, dir)
86 return fmt.Errorf("restore archive: %w", err)
89 _, err = fmt.Fprintln(out, "Restored", dir)
91 return fmt.Errorf("write restore answer: %w", err)
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)
103 return fmt.Errorf("find restore destination: %w", err)
106 entries, err := os.ReadDir(dir)
108 return fmt.Errorf("read restore destination: %w", err)
111 if len(entries) != 0 {
113 for _, entry := range entries {
114 hasLock = hasLock || entry.Name() == tokenLockFileName
118 return errors.New("restore needs an empty data directory")
122 tokens, err := lockTokens(dir)
124 return fmt.Errorf("restore needs a stopped daemon: %w", err)
128 err = errors.Join(err, tokens.Close())
131 if len(entries) != 0 {
132 return errors.New("restore needs an empty data directory")
135 entries, err = os.ReadDir(dir)
137 return fmt.Errorf("check restore destination: %w", err)
140 if len(entries) != 1 || entries[0].Name() != tokenLockFileName {
141 return errors.New("restore needs an empty data directory")
144 stage, err := os.MkdirTemp(filepath.Dir(dir), ".onechat-restore-*")
146 return fmt.Errorf("create restore directory: %w", err)
150 err = errors.Join(err, os.RemoveAll(stage))
153 err = backupExtract(ctx, path, stage)
155 return fmt.Errorf("extract backup: %w", err)
158 entries, err = os.ReadDir(stage)
160 return fmt.Errorf("read restored files: %w", err)
163 for _, entry := range entries {
164 err = os.Rename(filepath.Join(stage, entry.Name()), filepath.Join(dir, entry.Name()))
166 return fmt.Errorf("install restored entry: %w", err)
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")
180 stage, err := os.MkdirTemp("", "onechat-backup-*")
182 return 0, fmt.Errorf("create backup directory: %w", err)
186 err = errors.Join(err, os.RemoveAll(stage))
189 err = whatsapp.BackupSession(ctx, filepath.Join(stage, whatsappDirName, whatsappSessionFileName))
191 return 0, fmt.Errorf("copy WhatsApp session: %w", err)
194 err = snapshot(ctx, func() error {
195 return backupCopy(ctx, dir, stage, path)
198 return 0, fmt.Errorf("copy data directory: %w", err)
201 size, err = backupArchive(ctx, stage, path)
203 return 0, fmt.Errorf("archive backup: %w", err)
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
214 modes := make(map[string]fs.FileMode)
216 err := filepath.WalkDir(source, func(path string, entry fs.DirEntry, err error) error {
218 return fmt.Errorf("read data entry: %w", err)
221 name, err := filepath.Rel(source, path)
223 return fmt.Errorf("name data entry: %w", err)
230 absolute, err := filepath.Abs(path)
232 return fmt.Errorf("find data entry: %w", err)
235 session := filepath.Join(whatsappDirName, whatsappSessionFileName)
236 if backupExcluded(name) || absolute == output || name == session || strings.HasPrefix(name, session+"-") {
238 return filepath.SkipDir
244 info, err := entry.Info()
246 return fmt.Errorf("read data file: %w", err)
249 destination := filepath.Join(target, name)
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)
258 return fmt.Errorf("unsupported data entry %q", name)
262 return fmt.Errorf("stage data entry %q: %w", name, err)
268 return fmt.Errorf("stage backup: %w", err)
271 for i := len(directories) - 1; i >= 0; i-- {
272 path := directories[i]
273 err = os.Chmod(path, modes[path])
275 return fmt.Errorf("stage directory mode: %w", err)
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)
287 return fmt.Errorf("open snapshot source: %w", err)
291 err = errors.Join(err, input.Close())
294 info, err := input.Stat()
296 return fmt.Errorf("read snapshot source: %w", err)
299 if !info.Mode().IsRegular() {
300 return errors.New("snapshot source must be a regular file")
303 file, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
305 return fmt.Errorf("create staged file: %w", err)
309 err = errors.Join(err, file.Close())
312 _, err = io.CopyN(file, BackupReader{
317 return fmt.Errorf("stage file: %w", err)
320 err = file.Chmod(info.Mode().Perm())
322 return fmt.Errorf("stage file mode: %w", err)