package main import ( "archive/tar" "compress/gzip" "errors" "fmt" "io" "os" "path/filepath" "strings" ) func BackupDir(src string, dst string) error { out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) if err != nil { return err } defer out.Close() gz := gzip.NewWriter(out) defer gz.Close() tw := tar.NewWriter(gz) defer tw.Close() root, err := filepath.Abs(src) if err != nil { return err } return filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { if err != nil || entry.IsDir() { return err } if entry.Type()&os.ModeSymlink != 0 { return fmt.Errorf("refusing to back up symlink %q", path) } rel, err := filepath.Rel(root, path) if err != nil { return err } info, err := entry.Info() if err != nil { return err } header, err := tar.FileInfoHeader(info, "") if err != nil { return err } header.Name = filepath.ToSlash(rel) if err := tw.WriteHeader(header); err != nil { return err } file, err := os.Open(path) if err != nil { return err } fileInfo, statErr := file.Stat() currentInfo, lstatErr := os.Lstat(path) if statErr != nil || lstatErr != nil { _ = file.Close() if statErr != nil { return statErr } return lstatErr } if currentInfo.Mode()&os.ModeSymlink != 0 || !os.SameFile(currentInfo, fileInfo) { _ = file.Close() return fmt.Errorf("refusing to back up replaced or symlinked file %q", path) } _, copyErr := io.Copy(tw, file) closeErr := file.Close() if copyErr != nil { return copyErr } return closeErr }) } func RestoreDir(src string, dst string) error { return RestoreDirWithLimit(src, dst, defaultMaxRestoreBytes) } func RestoreDirWithLimit(src string, dst string, maxBytes int64) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() gz, err := gzip.NewReader(in) if err != nil { return err } defer gz.Close() tr := tar.NewReader(gz) root, err := filepath.Abs(dst) if err != nil { return err } if err := os.MkdirAll(root, 0o700); err != nil { return err } var restoredBytes int64 for { header, err := tr.Next() if err == io.EOF { return nil } if err != nil { return err } if strings.Contains(header.Name, "..") || strings.HasPrefix(header.Name, "/") { return fmt.Errorf("unsafe archive path %q", header.Name) } if header.Typeflag != tar.TypeReg && header.Typeflag != tar.TypeRegA && header.Typeflag != tar.TypeDir { return fmt.Errorf("unsupported archive entry %q", header.Name) } if header.Size < 0 { return fmt.Errorf("invalid archive size for %q", header.Name) } if header.Typeflag == tar.TypeReg || header.Typeflag == tar.TypeRegA { restoredBytes += header.Size if maxBytes > 0 && restoredBytes > maxBytes { return fmt.Errorf("restore exceeds maximum size") } } path := filepath.Join(root, filepath.FromSlash(header.Name)) if !strings.HasPrefix(path, root+string(os.PathSeparator)) && path != root { return fmt.Errorf("unsafe archive path %q", header.Name) } if err := rejectSymlinkParents(root, path); err != nil { return err } if header.FileInfo().IsDir() { if err := os.MkdirAll(path, 0o700); err != nil { return err } continue } if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return err } if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 { return fmt.Errorf("refusing to restore through symlink %q", header.Name) } out, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) if err != nil { return err } _, copyErr := io.CopyN(out, tr, header.Size) closeErr := out.Close() if copyErr != nil { return copyErr } if closeErr != nil { return closeErr } } } func rejectSymlinkParents(root string, path string) error { current := root rel, err := filepath.Rel(root, filepath.Dir(path)) if err != nil { return err } if rel == "." { return nil } for _, part := range strings.Split(rel, string(os.PathSeparator)) { current = filepath.Join(current, part) info, err := os.Lstat(current) if errors.Is(err, os.ErrNotExist) { return nil } if err != nil { return err } if info.Mode()&os.ModeSymlink != 0 { return fmt.Errorf("refusing to restore through symlink parent %q", current) } } return nil }