package main import ( "context" "encoding/json" "errors" "fmt" "io" "io/fs" "net" "os" "path/filepath" "strings" ) // backup asks the daemon for a private archive and prints its path and size. func backup(ctx context.Context, path string, out io.Writer) (err error) { dir, err := dataDir() if err != nil { return fmt.Errorf("find data directory: %w", err) } path, err = filepath.Abs(path) if err != nil { return fmt.Errorf("find backup path: %w", err) } var dialer net.Dialer conn, err := dialer.DialContext(ctx, "unix", filepath.Join(dir, socketFileName)) if err != nil { return errors.New("backup needs a running daemon") } defer func() { err = errors.Join(err, conn.Close()) }() stop := context.AfterFunc(ctx, func() { _ = conn.Close() }) defer stop() err = json.NewEncoder(conn).Encode(SocketRequest{ Type: "backup", Text: path, }) if err != nil { return fmt.Errorf("send backup request: %w", err) } decoder := json.NewDecoder(conn) for { var event Event err = decoder.Decode(&event) if err != nil { return fmt.Errorf("read backup answer: %w", err) } switch event.Kind { case "backup": _, err = fmt.Fprintln(out, event.Text) if err != nil { return fmt.Errorf("write backup answer: %w", err) } return nil case "error": return fmt.Errorf("backup failed: %s", event.Text) } } } // restore runs the offline restore command and reports its data directory. func restore(ctx context.Context, path string, out io.Writer) error { dir, err := dataDir() if err != nil { return fmt.Errorf("find restore directory: %w", err) } err = backupRestore(ctx, path, dir) if err != nil { return fmt.Errorf("restore archive: %w", err) } _, err = fmt.Fprintln(out, "Restored", dir) if err != nil { return fmt.Errorf("write restore answer: %w", err) } return nil } // backupData stages a session snapshot, then copies the data under snapshot's // write locks. Compression runs after the locks are released. func backupData(ctx context.Context, dir, path string, whatsapp *WhatsApp, snapshot func(context.Context, func() error) error) (size int64, err error) { if !filepath.IsAbs(path) { return 0, errors.New("backup path must be absolute") } stage, err := os.MkdirTemp("", "onechat-backup-*") if err != nil { return 0, fmt.Errorf("create backup directory: %w", err) } defer func() { err = errors.Join(err, os.RemoveAll(stage)) }() err = whatsapp.BackupSession(ctx, filepath.Join(stage, whatsappDirName, whatsappSessionFileName)) if err != nil { return 0, fmt.Errorf("copy WhatsApp session: %w", err) } err = snapshot(ctx, func() error { return backupCopy(ctx, dir, stage, path) }) if err != nil { return 0, fmt.Errorf("copy data directory: %w", err) } size, err = backupArchive(ctx, stage, path) if err != nil { return 0, fmt.Errorf("archive backup: %w", err) } return size, nil } // backupRestore installs a validated archive into an empty data directory while // holding the token lock. It keeps the lock file so daemon starts use that same // inode, including after a failed restore. func backupRestore(ctx context.Context, path, dir string) (err error) { dir, err = filepath.Abs(dir) if err != nil { return fmt.Errorf("find restore destination: %w", err) } entries, err := os.ReadDir(dir) if err != nil { return fmt.Errorf("read restore destination: %w", err) } if len(entries) != 0 { hasLock := false for _, entry := range entries { hasLock = hasLock || entry.Name() == tokenLockFileName } if !hasLock { return errors.New("restore needs an empty data directory") } } tokens, err := lockTokens(dir) if err != nil { return fmt.Errorf("restore needs a stopped daemon: %w", err) } defer func() { err = errors.Join(err, tokens.Close()) }() if len(entries) != 0 { return errors.New("restore needs an empty data directory") } entries, err = os.ReadDir(dir) if err != nil { return fmt.Errorf("check restore destination: %w", err) } if len(entries) != 1 || entries[0].Name() != tokenLockFileName { return errors.New("restore needs an empty data directory") } stage, err := os.MkdirTemp(filepath.Dir(dir), ".onechat-restore-*") if err != nil { return fmt.Errorf("create restore directory: %w", err) } defer func() { err = errors.Join(err, os.RemoveAll(stage)) }() err = backupExtract(ctx, path, stage) if err != nil { return fmt.Errorf("extract backup: %w", err) } entries, err = os.ReadDir(stage) if err != nil { return fmt.Errorf("read restored files: %w", err) } for _, entry := range entries { err = os.Rename(filepath.Join(stage, entry.Name()), filepath.Join(dir, entry.Name())) if err != nil { return fmt.Errorf("install restored entry: %w", err) } } return nil } // backupCopy stages regular files and directories, excluding runtime files, the // output archive, and raw session database files. Each file keeps its mode. func backupCopy(ctx context.Context, source, target, output string) error { var directories []string modes := make(map[string]fs.FileMode) err := filepath.WalkDir(source, func(path string, entry fs.DirEntry, err error) error { if err != nil { return fmt.Errorf("read data entry: %w", err) } name, err := filepath.Rel(source, path) if err != nil { return fmt.Errorf("name data entry: %w", err) } if name == "." { return nil } absolute, err := filepath.Abs(path) if err != nil { return fmt.Errorf("find data entry: %w", err) } session := filepath.Join(whatsappDirName, whatsappSessionFileName) if backupExcluded(name) || absolute == output || name == session || strings.HasPrefix(name, session+"-") { if entry.IsDir() { return filepath.SkipDir } return nil } info, err := entry.Info() if err != nil { return fmt.Errorf("read data file: %w", err) } destination := filepath.Join(target, name) switch { case info.IsDir(): err = os.MkdirAll(destination, 0o700) directories = append(directories, destination) modes[destination] = info.Mode().Perm() case info.Mode().IsRegular(): err = backupStageFile(ctx, path, destination) default: return fmt.Errorf("unsupported data entry %q", name) } if err != nil { return fmt.Errorf("stage data entry %q: %w", name, err) } return nil }) if err != nil { return fmt.Errorf("stage backup: %w", err) } for i := len(directories) - 1; i >= 0; i-- { path := directories[i] err = os.Chmod(path, modes[path]) if err != nil { return fmt.Errorf("stage directory mode: %w", err) } } return nil } // backupStageFile opens before it reads size and mode, so an atomic state file // replacement cannot join the metadata of one version to the bytes of another. func backupStageFile(ctx context.Context, source, target string) (err error) { input, err := os.Open(source) if err != nil { return fmt.Errorf("open snapshot source: %w", err) } defer func() { err = errors.Join(err, input.Close()) }() info, err := input.Stat() if err != nil { return fmt.Errorf("read snapshot source: %w", err) } if !info.Mode().IsRegular() { return errors.New("snapshot source must be a regular file") } file, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) if err != nil { return fmt.Errorf("create staged file: %w", err) } defer func() { err = errors.Join(err, file.Close()) }() _, err = io.CopyN(file, BackupReader{ ctx: ctx, reader: input, }, info.Size()) if err != nil { return fmt.Errorf("stage file: %w", err) } err = file.Chmod(info.Mode().Perm()) if err != nil { return fmt.Errorf("stage file mode: %w", err) } return nil }