Address run download feedback

- With no arguments in TTY mode, prompt which artifacts to download
- Change `--pattern` argument to be just `--name` and only do exact
  matching
- For multi-archive downloads, prefix the destination path with the name
  of the artifact
- Add tests exercising HTTP functionality
- Avoid "zipslip" path injection when extracting ZIP files
- Add tests for ZIP extraction
This commit is contained in:
Mislav Marohnić 2021-04-07 19:56:28 +02:00
parent a4a41c23e6
commit 0e94de1ce6
6 changed files with 400 additions and 97 deletions

View file

@ -5,25 +5,33 @@ import (
"fmt"
"path/filepath"
"github.com/AlecAivazis/survey/v2"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/prompt"
"github.com/cli/cli/pkg/set"
"github.com/spf13/cobra"
)
type DownloadOptions struct {
IO *iostreams.IOStreams
Platform platform
Prompter prompter
DoPrompt bool
RunID string
DestinationDir string
FilePatterns []string
Names []string
}
type platform interface {
List(runID string) ([]Artifact, error)
Download(url string, dir string) error
}
type prompter interface {
Prompt(message string, options []string, result interface{}) error
}
func NewCmdDownload(f *cmdutil.Factory, runF func(*DownloadOptions) error) *cobra.Command {
opts := &DownloadOptions{
@ -33,22 +41,29 @@ func NewCmdDownload(f *cmdutil.Factory, runF func(*DownloadOptions) error) *cobr
cmd := &cobra.Command{
Use: "download [<run-id>]",
Short: "Download artifacts generated by a workflow run",
Args: cobra.MaximumNArgs(1),
Long: heredoc.Doc(`
Download artifacts generated by a GitHub Actions workflow run.
The contents of each artifact will be extracted under separate directories based on
the artifact name. If only a single artifact is specified, it will be extracted into
the current directory.
`),
Args: cobra.MaximumNArgs(1),
Example: heredoc.Doc(`
# Download all artifacts generated by a workflow run
$ gh run download <run-id>
# Download a specific artifact within a run
$ gh run download <run-id> -p <name>
$ gh run download <run-id> -n <name>
# Download specific artifacts across all runs in a repository
$ gh run download -p <name1> -p <name2>
$ gh run download -n <name1> -n <name2>
`),
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
opts.RunID = args[0]
} else if len(opts.FilePatterns) == 0 {
return &cmdutil.FlagError{Err: errors.New("either run ID or `--pattern` is required")}
} else if len(opts.Names) == 0 && opts.IO.CanPrompt() {
opts.DoPrompt = true
}
// support `-R, --repo` override
@ -64,6 +79,7 @@ func NewCmdDownload(f *cmdutil.Factory, runF func(*DownloadOptions) error) *cobr
client: httpClient,
repo: baseRepo,
}
opts.Prompter = &surveyPrompter{}
if runF != nil {
return runF(opts)
@ -73,47 +89,79 @@ func NewCmdDownload(f *cmdutil.Factory, runF func(*DownloadOptions) error) *cobr
}
cmd.Flags().StringVarP(&opts.DestinationDir, "dir", "D", ".", "The directory to download artifacts into")
cmd.Flags().StringArrayVarP(&opts.FilePatterns, "pattern", "p", nil, "Download only artifacts that match a glob pattern")
cmd.Flags().StringArrayVarP(&opts.Names, "name", "n", nil, "Only download artifacts that match any of the given names")
return cmd
}
func runDownload(opts *DownloadOptions) error {
opts.IO.StartProgressIndicator()
defer opts.IO.StopProgressIndicator()
artifacts, err := opts.Platform.List(opts.RunID)
opts.IO.StopProgressIndicator()
if err != nil {
return fmt.Errorf("error fetching artifacts: %w", err)
}
// track downloaded artifacts and avoid re-downloading any of the same name
downloaded := map[string]struct{}{}
numArtifacts := 0
numValidArtifacts := 0
for _, a := range artifacts {
if a.Expired {
continue
}
numArtifacts++
if _, found := downloaded[a.Name]; found {
numValidArtifacts++
}
if numValidArtifacts == 0 {
return errors.New("no valid artifacts found to download")
}
wantNames := opts.Names
if opts.DoPrompt {
artifactNames := set.NewStringSet()
for _, a := range artifacts {
if !a.Expired {
artifactNames.Add(a.Name)
}
}
options := artifactNames.ToSlice()
if len(options) > 10 {
options = options[:10]
}
err := opts.Prompter.Prompt("Select artifacts to download:", options, &wantNames)
if err != nil {
return err
}
if len(wantNames) == 0 {
return errors.New("no artifacts selected")
}
}
opts.IO.StartProgressIndicator()
defer opts.IO.StopProgressIndicator()
// track downloaded artifacts and avoid re-downloading any of the same name
downloaded := set.NewStringSet()
for _, a := range artifacts {
if a.Expired {
continue
}
if len(opts.FilePatterns) > 0 && !matchAny(opts.FilePatterns, a.Name) {
if downloaded.Contains(a.Name) {
continue
}
err := opts.Platform.Download(a.DownloadURL, opts.DestinationDir)
if len(wantNames) > 0 && !matchAny(wantNames, a.Name) {
continue
}
destDir := opts.DestinationDir
if len(wantNames) != 1 {
destDir = filepath.Join(destDir, a.Name)
}
err := opts.Platform.Download(a.DownloadURL, destDir)
if err != nil {
return fmt.Errorf("error downloading %s: %w", a.Name, err)
}
downloaded[a.Name] = struct{}{}
downloaded.Add(a.Name)
}
if numArtifacts == 0 {
return errors.New("no valid artifacts found to download")
}
if len(downloaded) == 0 {
return errors.New("no artifact matches any of the patterns provided")
if downloaded.Len() == 0 {
return errors.New("no artifact matches any of the names provided")
}
return nil
@ -121,9 +169,18 @@ func runDownload(opts *DownloadOptions) error {
func matchAny(patterns []string, name string) bool {
for _, p := range patterns {
if isMatch, err := filepath.Match(p, name); err == nil && isMatch {
if name == p {
return true
}
}
return false
}
type surveyPrompter struct{}
func (sp *surveyPrompter) Prompt(message string, options []string, result interface{}) error {
return prompt.SurveyAskOne(&survey.MultiSelect{
Message: message,
Options: options,
}, result)
}