Move the archive entry name validation added for CVE-2026-59732 from
cmd/archive/extract into a new lib/sanitize package as sanitize.Path,
so the same check can be shared with the archive backend which mounts
archives as a filesystem.
sanitize.Path keeps the extract semantics - reject any name with a
".." path component, treating both "/" and "\" as separators - and
additionally cleans the name with path.Clean. This corrects two edge
cases in extract: a repeated "./" prefix ("././file.txt") is now fully
stripped rather than only the first, and a bare "." entry is now
treated as the archive root and skipped.
Add sanitize.Leaf, which rejects a name that is empty, ".", ".." or
contains a "/", for checking a single directory entry name read from
an archive.
The names handled are rclone remote paths, in which "/" is the only
separator and "\" an ordinary character, so Leaf does not reject a
backslash: making a name safe for its storage is the destination
backend's job (the local backend encodes "\" on Windows and refuses
paths which escape its root). Path's rejection of ".." between
backslashes is kept as defence in depth for extract.
228 lines
6.2 KiB
Go
228 lines
6.2 KiB
Go
//go:build !plan9
|
|
|
|
// Package extract implements 'rclone archive extract'
|
|
package extract
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"path"
|
|
"strings"
|
|
|
|
"github.com/mholt/archives"
|
|
"github.com/rclone/rclone/cmd"
|
|
"github.com/rclone/rclone/cmd/archive"
|
|
"github.com/rclone/rclone/fs"
|
|
"github.com/rclone/rclone/fs/accounting"
|
|
"github.com/rclone/rclone/fs/filter"
|
|
"github.com/rclone/rclone/fs/operations"
|
|
"github.com/rclone/rclone/lib/sanitize"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
func init() {
|
|
archive.Command.AddCommand(Command)
|
|
}
|
|
|
|
// Command - extract
|
|
var Command = &cobra.Command{
|
|
Use: "extract [flags] <source> <destination>",
|
|
Short: `Extract archives from source to destination.`,
|
|
Long: strings.ReplaceAll(`
|
|
|
|
Extract the archive contents to a destination directory auto detecting
|
|
the format. See [rclone archive create](/commands/rclone_archive_create/)
|
|
for the archive formats supported.
|
|
|
|
For example on this archive:
|
|
|
|
|||
|
|
$ rclone archive list --long remote:archive.zip
|
|
6 2025-10-30 09:46:23.000000000 file.txt
|
|
0 2025-10-30 09:46:57.000000000 dir/
|
|
4 2025-10-30 09:46:57.000000000 dir/bye.txt
|
|
|||
|
|
|
|
You can run extract like this
|
|
|
|
|||
|
|
$ rclone archive extract remote:archive.zip remote:extracted
|
|
|||
|
|
|
|
Which gives this result
|
|
|
|
|||
|
|
$ rclone tree remote:extracted
|
|
/
|
|
├── dir
|
|
│ └── bye.txt
|
|
└── file.txt
|
|
|||
|
|
|
|
The source or destination or both can be local or remote.
|
|
|
|
Filters can be used to only extract certain files:
|
|
|
|
|||
|
|
$ rclone archive extract archive.zip partial --include "bye.*"
|
|
$ rclone tree partial
|
|
/
|
|
└── dir
|
|
└── bye.txt
|
|
|||
|
|
|
|
The [archive backend](/archive/) can also be used to extract files. It
|
|
can be used to read only mount archives also but it supports a
|
|
different set of archive formats to the archive commands.
|
|
`, "|", "`"),
|
|
Annotations: map[string]string{
|
|
"versionIntroduced": "v1.72",
|
|
},
|
|
RunE: func(command *cobra.Command, args []string) error {
|
|
cmd.CheckArgs(2, 2, command, args)
|
|
|
|
src, srcFile := cmd.NewFsFile(args[0])
|
|
dst, dstFile := cmd.NewFsFile(args[1])
|
|
|
|
cmd.Run(false, false, command, func() error {
|
|
return ArchiveExtract(context.Background(), dst, dstFile, src, srcFile)
|
|
})
|
|
return nil
|
|
},
|
|
}
|
|
|
|
// ArchiveExtract extracts files from (src, srcFile) to (dst, dstDir)
|
|
func ArchiveExtract(ctx context.Context, dst fs.Fs, dstDir string, src fs.Fs, srcFile string) error {
|
|
var srcObj fs.Object
|
|
var filesExtracted = 0
|
|
var err error
|
|
|
|
fi := filter.GetConfig(ctx)
|
|
ci := fs.GetConfig(ctx)
|
|
// get source object
|
|
srcObj, err = src.NewObject(ctx, srcFile)
|
|
fs.Debugf(nil, "srcFile: %q, src : %v", srcFile, src)
|
|
if errors.Is(err, fs.ErrorIsDir) {
|
|
return fmt.Errorf("source can't be a directory: %w", err)
|
|
} else if errors.Is(err, fs.ErrorObjectNotFound) {
|
|
return fmt.Errorf("source not found: %w", err)
|
|
} else if err != nil {
|
|
return fmt.Errorf("unable to access source: %w", err)
|
|
}
|
|
fs.Debugf(nil, "Source archive file: %s/%s", src.Root(), srcFile)
|
|
// Create destination directory
|
|
err = dst.Mkdir(ctx, dstDir)
|
|
if err != nil {
|
|
return fmt.Errorf("unable to access destination: %w", err)
|
|
}
|
|
|
|
fs.Debugf(dst, "Destination for extracted files: %q", dstDir)
|
|
// start accounting
|
|
tr := accounting.Stats(ctx).NewTransfer(srcObj, nil)
|
|
defer tr.Done(ctx, err)
|
|
// open source
|
|
var options []fs.OpenOption
|
|
for _, option := range fs.GetConfig(ctx).DownloadHeaders {
|
|
options = append(options, option)
|
|
}
|
|
in0, err := operations.Open(ctx, srcObj, options...)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open file %s: %w", srcFile, err)
|
|
}
|
|
// account and buffer the transfer
|
|
// in = tr.Account(ctx, in).WithBuffer()
|
|
acc := tr.Account(ctx, in0)
|
|
in, err := acc.WithReadAtSeeker()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// identify format
|
|
format, _, err := archives.Identify(ctx, "", in)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open check file type: %w", err)
|
|
}
|
|
fs.Debugf(nil, "Extract %s/%s, format %s to %s", src.Root(), srcFile, strings.TrimPrefix(format.Extension(), "."), dst.Root())
|
|
|
|
// check if extract is supported by format
|
|
ex, isExtract := format.(archives.Extraction)
|
|
if !isExtract {
|
|
return fmt.Errorf("extraction for %s not supported", strings.TrimPrefix(format.Extension(), "."))
|
|
}
|
|
// extract files
|
|
err = ex.Extract(ctx, in, func(ctx context.Context, f archives.FileInfo) error {
|
|
remote, err := destPath(f.NameInArchive, dstDir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Skip the archive root entry ("./") which has no name of its own.
|
|
if remote == "" {
|
|
return nil
|
|
}
|
|
// check if file should be extracted
|
|
if !fi.Include(remote, f.Size(), f.ModTime(), fs.Metadata{}) {
|
|
return nil
|
|
}
|
|
// process directory
|
|
if f.IsDir() {
|
|
// directory
|
|
fs.Debugf(nil, "mkdir %s", remote)
|
|
// leave if --dry-run set
|
|
if ci.DryRun {
|
|
return nil
|
|
}
|
|
// create the directory
|
|
return operations.Mkdir(ctx, dst, remote)
|
|
}
|
|
// process file
|
|
fs.Debugf(nil, "Extract %s", remote)
|
|
// leave if --dry-run set
|
|
if ci.DryRun {
|
|
filesExtracted++
|
|
return nil
|
|
}
|
|
// open file
|
|
fin, err := f.Open()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// extract the file to destination
|
|
_, err = operations.Rcat(ctx, dst, remote, fin, f.ModTime(), nil)
|
|
if err == nil {
|
|
filesExtracted++
|
|
}
|
|
return err
|
|
})
|
|
|
|
fs.Infof(nil, "Total files extracted %d", filesExtracted)
|
|
|
|
return err
|
|
}
|
|
|
|
// destPath maps an archive entry name onto its destination remote within
|
|
// dstDir, returning an error if the name is unsafe.
|
|
//
|
|
// Archive entry names are attacker controlled so they are sanitized with
|
|
// sanitize.Path before being joined onto dstDir. This cleans the name
|
|
// (stripping the "./" prefix which tar archives created with relative
|
|
// paths, e.g. "tar -czf archive.tar.gz .", put on their entries) and
|
|
// rejects any name with a ".." path component which would otherwise
|
|
// escape the destination directory (a path traversal, or "Zip Slip",
|
|
// attack).
|
|
//
|
|
// The returned remote is empty for the archive root entry ("./"), which the
|
|
// caller should skip.
|
|
func destPath(nameInArchive, dstDir string) (string, error) {
|
|
remote, err := sanitize.Path(nameInArchive)
|
|
if err != nil {
|
|
return "", fmt.Errorf("refusing to extract archive entry: %w", err)
|
|
}
|
|
if remote == "" {
|
|
return "", nil
|
|
}
|
|
if dstDir != "" {
|
|
remote = path.Join(dstDir, remote)
|
|
}
|
|
return remote, nil
|
|
}
|