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() {
44 err = json.NewEncoder(conn).Encode(SocketRequest{
49 return fmt.Errorf("send backup request: %w", err)
52 decoder := json.NewDecoder(conn)
56 err = decoder.Decode(&event)
58 return fmt.Errorf("read backup answer: %w", err)
63 _, err = fmt.Fprintln(out, event.Text)
65 return fmt.Errorf("write backup answer: %w", err)
70 return fmt.Errorf("backup failed: %s", event.Text)
75 // restore runs the offline restore command and reports its data directory.
76 func restore(ctx context.Context, path string, out io.Writer) error {
79 return fmt.Errorf("find restore directory: %w", err)
82 err = backupRestore(ctx, path, dir)
84 return fmt.Errorf("restore archive: %w", err)
87 _, err = fmt.Fprintln(out, "Restored", dir)
89 return fmt.Errorf("write restore answer: %w", err)
95 // backupData stages a session snapshot, then copies the data under snapshot's
96 // write locks. Compression runs after the locks are released.
97 func backupData(ctx context.Context, dir, path string, whatsapp *WhatsApp, snapshot func(context.Context, func() error) error) (size int64, err error) {
98 if !filepath.IsAbs(path) {
99 return 0, errors.New("backup path must be absolute")
102 stage, err := os.MkdirTemp("", "onechat-backup-*")
104 return 0, fmt.Errorf("create backup directory: %w", err)
108 err = errors.Join(err, os.RemoveAll(stage))
111 err = whatsapp.BackupSession(ctx, filepath.Join(stage, whatsappDirName, whatsappSessionFileName))
113 return 0, fmt.Errorf("copy WhatsApp session: %w", err)
116 err = snapshot(ctx, func() error {
117 return backupCopy(ctx, dir, stage, path)
120 return 0, fmt.Errorf("copy data directory: %w", err)
123 size, err = backupArchive(ctx, stage, path)
125 return 0, fmt.Errorf("archive backup: %w", err)
131 // backupRestore installs a validated archive into an empty data directory while
132 // holding the token lock. It keeps the lock file so daemon starts use that same
133 // inode, including after a failed restore.
134 func backupRestore(ctx context.Context, path, dir string) (err error) {
135 dir, err = filepath.Abs(dir)
137 return fmt.Errorf("find restore destination: %w", err)
140 entries, err := os.ReadDir(dir)
142 return fmt.Errorf("read restore destination: %w", err)
145 if len(entries) != 0 {
147 for _, entry := range entries {
148 hasLock = hasLock || entry.Name() == tokenLockFileName
152 return errors.New("restore needs an empty data directory")
156 tokens, err := lockTokens(dir)
158 return fmt.Errorf("restore needs a stopped daemon: %w", err)
162 err = errors.Join(err, tokens.Close())
165 if len(entries) != 0 {
166 return errors.New("restore needs an empty data directory")
169 entries, err = os.ReadDir(dir)
171 return fmt.Errorf("check restore destination: %w", err)
174 if len(entries) != 1 || entries[0].Name() != tokenLockFileName {
175 return errors.New("restore needs an empty data directory")
178 stage, err := os.MkdirTemp(filepath.Dir(dir), ".onechat-restore-*")
180 return fmt.Errorf("create restore directory: %w", err)
184 err = errors.Join(err, os.RemoveAll(stage))
187 err = backupExtract(ctx, path, stage)
189 return fmt.Errorf("extract backup: %w", err)
192 entries, err = os.ReadDir(stage)
194 return fmt.Errorf("read restored files: %w", err)
197 for _, entry := range entries {
198 err = os.Rename(filepath.Join(stage, entry.Name()), filepath.Join(dir, entry.Name()))
200 return fmt.Errorf("install restored entry: %w", err)
207 // backupCopy stages regular files and directories, excluding runtime files, the
208 // output archive, and raw session database files. Each file keeps its mode.
209 func backupCopy(ctx context.Context, source, target, output string) error {
210 var directories []string
212 modes := make(map[string]fs.FileMode)
214 err := filepath.WalkDir(source, func(path string, entry fs.DirEntry, err error) error {
216 return fmt.Errorf("read data entry: %w", err)
219 name, err := filepath.Rel(source, path)
221 return fmt.Errorf("name data entry: %w", err)
228 absolute, err := filepath.Abs(path)
230 return fmt.Errorf("find data entry: %w", err)
233 session := filepath.Join(whatsappDirName, whatsappSessionFileName)
234 if backupExcluded(name) || absolute == output || name == session || strings.HasPrefix(name, session+"-") {
236 return filepath.SkipDir
242 info, err := entry.Info()
244 return fmt.Errorf("read data file: %w", err)
247 destination := filepath.Join(target, name)
250 err = os.MkdirAll(destination, 0o700)
251 directories = append(directories, destination)
252 modes[destination] = info.Mode().Perm()
253 case info.Mode().IsRegular():
254 err = backupStageFile(ctx, path, destination)
256 return fmt.Errorf("unsupported data entry %q", name)
260 return fmt.Errorf("stage data entry %q: %w", name, err)
266 return fmt.Errorf("stage backup: %w", err)
269 for i := len(directories) - 1; i >= 0; i-- {
270 path := directories[i]
271 err = os.Chmod(path, modes[path])
273 return fmt.Errorf("stage directory mode: %w", err)
280 // backupStageFile opens before it reads size and mode, so an atomic state file
281 // replacement cannot join the metadata of one version to the bytes of another.
282 func backupStageFile(ctx context.Context, source, target string) (err error) {
283 input, err := os.Open(source)
285 return fmt.Errorf("open snapshot source: %w", err)
289 err = errors.Join(err, input.Close())
292 info, err := input.Stat()
294 return fmt.Errorf("read snapshot source: %w", err)
297 if !info.Mode().IsRegular() {
298 return errors.New("snapshot source must be a regular file")
301 file, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
303 return fmt.Errorf("create staged file: %w", err)
307 err = errors.Join(err, file.Close())
310 _, err = io.CopyN(file, BackupReader{
315 return fmt.Errorf("stage file: %w", err)
318 err = file.Chmod(info.Mode().Perm())
320 return fmt.Errorf("stage file mode: %w", err)