Skip to content

Commit a78810b

Browse files
Shell task runner API implementation
Implemented the `snowblock.TaskRunner` API interface to handle `shell` tasks from the original Python implementation (1). References: (1) https://github.com/arcticicestudio/snowsaw/blob/3e3840824bf6f3d5cc09573b9505737473c7ed95/README.md#shell Epic: GH-33 Resolves GH-79
1 parent 9366c4a commit a78810b

File tree

2 files changed

+186
-0
lines changed

2 files changed

+186
-0
lines changed

pkg/config/constants.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/arcticicestudio/snowsaw/pkg/snowblock/task"
1818
"github.com/arcticicestudio/snowsaw/pkg/snowblock/task/clean"
1919
"github.com/arcticicestudio/snowsaw/pkg/snowblock/task/link"
20+
"github.com/arcticicestudio/snowsaw/pkg/snowblock/task/shell"
2021
)
2122

2223
const (
@@ -43,6 +44,7 @@ var (
4344
availableTaskRunner = []snowblock.TaskRunner{
4445
&clean.Clean{},
4546
&link.Link{},
47+
&shell.Shell{},
4648
}
4749

4850
// BuildDateTime is the date and time this application was build.

pkg/snowblock/task/shell/shell.go

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
// Copyright (C) 2017-present Arctic Ice Studio <[email protected]>
2+
// Copyright (C) 2017-present Sven Greb <[email protected]>
3+
//
4+
// Project: snowsaw
5+
// Repository: https://github.com/arcticicestudio/snowsaw
6+
// License: MIT
7+
8+
// Author: Arctic Ice Studio <[email protected]>
9+
// Author: Sven Greb <[email protected]>
10+
// Since: 0.4.0
11+
12+
// Package shell provides a task runner implementation to run arbitrary shell commands.
13+
package shell
14+
15+
import (
16+
"errors"
17+
"fmt"
18+
"os"
19+
"os/exec"
20+
"strings"
21+
22+
"github.com/fatih/color"
23+
"github.com/mitchellh/mapstructure"
24+
25+
"github.com/arcticicestudio/snowsaw/pkg/api/snowblock"
26+
"github.com/arcticicestudio/snowsaw/pkg/prt"
27+
"github.com/arcticicestudio/snowsaw/pkg/util/filesystem"
28+
)
29+
30+
const (
31+
// CommandConfigArrayMaxArgs is the maximum amount of values that are allowed when using an a array of strings
32+
// as shell configuration type.
33+
CommandConfigArrayMaxArgs = 2
34+
)
35+
36+
// Shell is a task runner to run arbitrary shell commands.
37+
type Shell struct {
38+
cmd string
39+
cmdArgs []string
40+
config *config
41+
snowblockAbsPath string
42+
}
43+
44+
type config struct {
45+
Command string `json:"command" yaml:"command"`
46+
Description string `json:"description" yaml:"description"`
47+
Stderr bool `json:"stderr" yaml:"stderr"`
48+
Stdin bool `json:"stdin" yaml:"stdin"`
49+
Stdout bool `json:"stdout" yaml:"stdout"`
50+
}
51+
52+
// GetTaskName returns the name of the task this runner can process.
53+
func (s Shell) GetTaskName() string {
54+
return "shell"
55+
}
56+
57+
// Run processes a task using the given task instructions.
58+
// The snowblockAbsPath parameter is the absolute path of the snowblock used as contextual information.
59+
func (s *Shell) Run(configuration snowblock.TaskConfiguration, snowblockAbsPath string) error {
60+
s.snowblockAbsPath = snowblockAbsPath
61+
62+
// Try to convert given task configurations...
63+
configMap, ok := configuration.([]interface{})
64+
if !ok {
65+
prt.Debugf("invalid shell configuration type: %s", color.RedString("%T", configuration))
66+
return errors.New("malformed shell configuration")
67+
}
68+
69+
// ...and handle the possible types.
70+
for idxConfigMap, configData := range configMap {
71+
s.config = &config{}
72+
s.cmd = ""
73+
s.cmdArgs = []string{}
74+
75+
switch configType := configData.(type) {
76+
// Handle JSON `object` configurations used to define a command with a description and additional options.
77+
case map[string]interface{}:
78+
if err := mapstructure.Decode(configType, &s.config); err != nil {
79+
return err
80+
}
81+
if parseCmdElErr := s.parseCommand(s.config.Command); parseCmdElErr != nil {
82+
return parseCmdElErr
83+
}
84+
if execErr := s.execute(); execErr != nil {
85+
return execErr
86+
}
87+
88+
// Handle JSON `string` configurations used to only specify a single command.
89+
case string:
90+
if parseCmdElErr := s.parseCommand(configType); parseCmdElErr != nil {
91+
return parseCmdElErr
92+
}
93+
s.config.Command = configType
94+
if execErr := s.execute(); execErr != nil {
95+
return execErr
96+
}
97+
98+
// Handle JSON `array` configurations storing `string` values used to specify a command with a description.
99+
case []interface{}:
100+
var configStringValues []string
101+
for idxConfigArray, value := range configType {
102+
configString, isStringValue := value.(string)
103+
if !isStringValue {
104+
prt.Debugf("Unsupported value in %s shell command configuration of type %s at index %s",
105+
color.CyanString("%d", idxConfigMap),
106+
color.RedString("%T", value),
107+
color.BlueString("%d", idxConfigArray))
108+
return fmt.Errorf("unsupported value in %d shell configuration at index %d: %v",
109+
idxConfigMap, idxConfigArray, value)
110+
}
111+
configStringValues = append(configStringValues, configString)
112+
}
113+
if len(configStringValues) > CommandConfigArrayMaxArgs || len(configStringValues) < CommandConfigArrayMaxArgs {
114+
return fmt.Errorf("invalid amount of shell command arguments, expected %d but got %d",
115+
CommandConfigArrayMaxArgs, len(configStringValues))
116+
}
117+
if parseCmdElErr := s.parseCommand(configStringValues[0]); parseCmdElErr != nil {
118+
return parseCmdElErr
119+
}
120+
s.config.Command = configStringValues[0]
121+
s.config.Description = configStringValues[1]
122+
if execErr := s.execute(); execErr != nil {
123+
return execErr
124+
}
125+
126+
// Reject invalid or unsupported JSON data structures.
127+
default:
128+
prt.Debugf("unsupported shell command configuration type: %s", color.RedString("%T", configType))
129+
return fmt.Errorf("unsupported shell command configuration at index %d", idxConfigMap)
130+
}
131+
}
132+
133+
return nil
134+
}
135+
136+
func (s *Shell) execute() error {
137+
cmd := exec.Command(s.cmd, s.cmdArgs...)
138+
cmd.Dir = s.snowblockAbsPath
139+
cmd.Env = os.Environ()
140+
141+
if s.config.Description != "" {
142+
prt.Infof(s.config.Description)
143+
}
144+
if s.config.Stderr {
145+
cmd.Stderr = os.Stderr
146+
}
147+
if s.config.Stdin {
148+
cmd.Stdin = os.Stdin
149+
}
150+
if s.config.Stdout {
151+
cmd.Stdout = os.Stdout
152+
}
153+
154+
runErr := cmd.Run()
155+
if runErr != nil {
156+
return fmt.Errorf("failed to execute shell command: %s",
157+
color.CyanString("%s %s", s.cmd, strings.Join(s.cmdArgs, " ")))
158+
}
159+
160+
return nil
161+
}
162+
163+
func (s *Shell) parseCommand(cmd string) error {
164+
parts := strings.Split(strings.TrimSpace(cmd), " ")
165+
if len(parts[0]) == 0 {
166+
return fmt.Errorf("shell command must not be empty or whitespace-only")
167+
}
168+
169+
// Simulate shell specific behavior by trying to expand possible environment and special variables.
170+
// Note that this is only necessary to keep the compatibility with the original Python implementation that runs
171+
// commands with a specific shell simulation flag in order to provide these features which is described as "strongly
172+
// discouraged" in the reference documentations because it makes the application vulnerable to "shell injection".
173+
for idx, part := range parts {
174+
expPart, partExpandErr := filesystem.ExpandPath(part)
175+
if partExpandErr != nil {
176+
return partExpandErr
177+
}
178+
parts[idx] = expPart
179+
}
180+
181+
s.cmd = parts[0]
182+
s.cmdArgs = append(s.cmdArgs, parts[1:]...)
183+
return nil
184+
}

0 commit comments

Comments
 (0)