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() {
45 err = json.NewEncoder(conn).Encode(SocketRequest{
50 return fmt.Errorf("send backup request: %w", err)
53 decoder := json.NewDecoder(conn)
57 err = decoder.Decode(&event)
59 return fmt.Errorf("read backup answer: %w", err)
64 _, err = fmt.Fprintln(out, event.Text)
66 return fmt.Errorf("write backup answer: %w", err)
71 return fmt.Errorf("backup failed: %s", event.Text)
76 // restore runs the offline restore command and reports its data directory.
77 func restore(ctx context.Context, path string, out io.Writer) error {
80 return fmt.Errorf("find restore directory: %w", err)
83 err = backupRestore(ctx, path, dir)
85 return fmt.Errorf("restore archive: %w", err)
88 _, err = fmt.Fprintln(out, "Restored", dir)
90 return fmt.Errorf("write restore answer: %w", err)
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")
103 stage, err := os.MkdirTemp("", "onechat-backup-*")
105 return 0, fmt.Errorf("create backup directory: %w", err)
109 err = errors.Join(err, os.RemoveAll(stage))
112 err = whatsapp.BackupSession(ctx, filepath.Join(stage, whatsappDirName, whatsappSessionFileName))
114 return 0, fmt.Errorf("copy WhatsApp session: %w", err)
117 err = snapshot(ctx, func() error {
118 return backupCopy(ctx, dir, stage, path)
121 return 0, fmt.Errorf("copy data directory: %w", err)
124 size, err = backupArchive(ctx, stage, path)
126 return 0, fmt.Errorf("archive backup: %w", err)
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)
138 return fmt.Errorf("find restore destination: %w", err)
141 entries, err := os.ReadDir(dir)
143 return fmt.Errorf("read restore destination: %w", err)
146 if len(entries) != 0 {
148 for _, entry := range entries {
149 hasLock = hasLock || entry.Name() == tokenLockFileName
153 return errors.New("restore needs an empty data directory")
157 tokens, err := lockTokens(dir)
159 return fmt.Errorf("restore needs a stopped daemon: %w", err)
163 err = errors.Join(err, tokens.Close())
166 if len(entries) != 0 {
167 return errors.New("restore needs an empty data directory")
170 entries, err = os.ReadDir(dir)
172 return fmt.Errorf("check restore destination: %w", err)
175 if len(entries) != 1 || entries[0].Name() != tokenLockFileName {
176 return errors.New("restore needs an empty data directory")
179 stage, err := os.MkdirTemp(filepath.Dir(dir), ".onechat-restore-*")
181 return fmt.Errorf("create restore directory: %w", err)
185 err = errors.Join(err, os.RemoveAll(stage))
188 err = backupExtract(ctx, path, stage)
190 return fmt.Errorf("extract backup: %w", err)
193 entries, err = os.ReadDir(stage)
195 return fmt.Errorf("read restored files: %w", err)
198 for _, entry := range entries {
199 err = os.Rename(filepath.Join(stage, entry.Name()), filepath.Join(dir, entry.Name()))
201 return fmt.Errorf("install restored entry: %w", err)
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
213 modes := make(map[string]fs.FileMode)
215 err := filepath.WalkDir(source, func(path string, entry fs.DirEntry, err error) error {
217 return fmt.Errorf("read data entry: %w", err)
220 name, err := filepath.Rel(source, path)
222 return fmt.Errorf("name data entry: %w", err)
229 absolute, err := filepath.Abs(path)
231 return fmt.Errorf("find data entry: %w", err)
234 session := filepath.Join(whatsappDirName, whatsappSessionFileName)
235 if backupExcluded(name) || absolute == output || name == session || strings.HasPrefix(name, session+"-") {
237 return filepath.SkipDir
243 info, err := entry.Info()
245 return fmt.Errorf("read data file: %w", err)
248 destination := filepath.Join(target, name)
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)
257 return fmt.Errorf("unsupported data entry %q", name)
261 return fmt.Errorf("stage data entry %q: %w", name, err)
267 return fmt.Errorf("stage backup: %w", err)
270 for i := len(directories) - 1; i >= 0; i-- {
271 path := directories[i]
272 err = os.Chmod(path, modes[path])
274 return fmt.Errorf("stage directory mode: %w", err)
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)
286 return fmt.Errorf("open snapshot source: %w", err)
290 err = errors.Join(err, input.Close())
293 info, err := input.Stat()
295 return fmt.Errorf("read snapshot source: %w", err)
298 if !info.Mode().IsRegular() {
299 return errors.New("snapshot source must be a regular file")
302 file, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
304 return fmt.Errorf("create staged file: %w", err)
308 err = errors.Join(err, file.Close())
311 _, err = io.CopyN(file, BackupReader{
316 return fmt.Errorf("stage file: %w", err)
319 err = file.Chmod(info.Mode().Perm())
321 return fmt.Errorf("stage file mode: %w", err)