Skip to content

Commit 332ae4e

Browse files
committed
Add file-decompress@v1 node
1 parent 2eafb0a commit 332ae4e

13 files changed

+534
-18
lines changed

cmd/cmd_dev_generate.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,9 @@ func createGoApi(projectRootDir string) error {
144144
for _, inputId := range inputIds {
145145
p := node.Inputs[inputId]
146146
if p.Desc != "" {
147-
fmt.Fprintf(fp, "// %v\n", p.Desc)
147+
for _, line := range strings.Split(strings.TrimRight(p.Desc, "\n"), "\n") {
148+
fmt.Fprintf(fp, "// %v\n", line)
149+
}
148150
}
149151
v := strings.ReplaceAll(string(inputId), "-", "_")
150152
fmt.Fprintf(fp, "const %v_Input_%v core.InputId = \"%v\"\n",
@@ -161,7 +163,9 @@ func createGoApi(projectRootDir string) error {
161163
for _, outputId := range outputIds {
162164
p := node.Outputs[outputId]
163165
if p.Desc != "" {
164-
fmt.Fprintf(fp, "// %v\n", p.Desc)
166+
for _, line := range strings.Split(strings.TrimRight(p.Desc, "\n"), "\n") {
167+
fmt.Fprintf(fp, "// %v\n", line)
168+
}
165169
}
166170
v := strings.ReplaceAll(string(outputId), "-", "_")
167171
fmt.Fprintf(fp, "const %v_Output_%v core.OutputId = \"%v\"\n", enumName, core.OutputId(v), outputId)

node_interfaces/interface_core_artifact-download_v1.go

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

node_interfaces/interface_core_artifact-upload_v1.go

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

node_interfaces/interface_core_docker_run_v1.go renamed to node_interfaces/interface_core_docker-run_v1.go

Lines changed: 17 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

node_interfaces/interface_core_file-decompress_v1.go

Lines changed: 24 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

node_interfaces/interface_core_parser_v1.go

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

node_interfaces/interface_core_repo-download_v1.go

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

nodes/file-decompress@v1.go

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
package nodes
2+
3+
import (
4+
"archive/tar"
5+
"archive/zip"
6+
"bytes"
7+
"compress/gzip"
8+
_ "embed"
9+
"fmt"
10+
"io"
11+
"os"
12+
"path/filepath"
13+
"strings"
14+
15+
"github.com/actionforge/actrun-cli/core"
16+
ni "github.com/actionforge/actrun-cli/node_interfaces"
17+
"github.com/actionforge/actrun-cli/utils"
18+
)
19+
20+
//go:embed file-decompress@v1.yml
21+
var fileDecompressDefinition string
22+
23+
type FileDecompressNode struct {
24+
core.NodeBaseComponent
25+
core.Executions
26+
core.Inputs
27+
core.Outputs
28+
}
29+
30+
func (n *FileDecompressNode) ExecuteImpl(c *core.ExecutionState, inputId core.InputId, prevError error) error {
31+
reader, err := core.InputValueById[io.Reader](c, n, ni.Core_file_decompress_v1_Input_data)
32+
if err != nil {
33+
return err
34+
}
35+
36+
defer utils.SafeCloseReaderAndIgnoreError(reader)
37+
38+
format, err := core.InputValueById[string](c, n, ni.Core_file_decompress_v1_Input_format)
39+
if err != nil {
40+
return err
41+
}
42+
43+
destPath, err := core.InputValueById[string](c, n, ni.Core_file_decompress_v1_Input_dest_path)
44+
if err != nil {
45+
return err
46+
}
47+
48+
if !filepath.IsAbs(destPath) {
49+
cwd, err := os.Getwd()
50+
if err != nil {
51+
return core.CreateErr(c, err, "failed to get current working directory")
52+
}
53+
destPath = filepath.Join(cwd, destPath)
54+
}
55+
56+
cleanDest, pathErr := utils.ValidatePath(destPath)
57+
if pathErr != nil {
58+
return core.CreateErr(c, pathErr, "invalid destination path")
59+
}
60+
61+
err = os.MkdirAll(cleanDest, 0o755)
62+
if err != nil {
63+
return core.CreateErr(c, err, "failed to create destination directory")
64+
}
65+
66+
var extractedFiles []string
67+
68+
switch format {
69+
case ZIP:
70+
extractedFiles, err = extractZipArchive(reader, cleanDest)
71+
case TAR:
72+
extractedFiles, err = extractTarArchive(reader, cleanDest)
73+
case TARGZ:
74+
gzReader, gzErr := gzip.NewReader(reader)
75+
if gzErr != nil {
76+
return core.CreateErr(c, gzErr, "failed to create gzip reader")
77+
}
78+
defer gzReader.Close()
79+
extractedFiles, err = extractTarArchive(gzReader, cleanDest)
80+
default:
81+
return core.CreateErr(c, nil, "unknown decompression format: %s", format)
82+
}
83+
84+
if err != nil {
85+
execErr := n.Execute(ni.Core_file_decompress_v1_Output_exec_err, c, err)
86+
if execErr != nil {
87+
return execErr
88+
}
89+
return nil
90+
}
91+
92+
err = n.Outputs.SetOutputValue(c, ni.Core_file_decompress_v1_Output_files, extractedFiles, core.SetOutputValueOpts{})
93+
if err != nil {
94+
return err
95+
}
96+
97+
err = n.Execute(ni.Core_file_decompress_v1_Output_exec_success, c, nil)
98+
if err != nil {
99+
return err
100+
}
101+
102+
return nil
103+
}
104+
105+
// extractTarArchive reads a tar stream and extracts regular files to destPath.
106+
// Symlinks are silently skipped.
107+
func extractTarArchive(reader io.Reader, destPath string) ([]string, error) {
108+
tr := tar.NewReader(reader)
109+
var extractedFiles []string
110+
111+
for {
112+
header, err := tr.Next()
113+
if err == io.EOF {
114+
break
115+
}
116+
if err != nil {
117+
return extractedFiles, fmt.Errorf("failed to read tar entry: %w", err)
118+
}
119+
120+
// Skip symlinks
121+
if header.Typeflag == tar.TypeSymlink || header.Typeflag == tar.TypeLink {
122+
continue
123+
}
124+
125+
target, err := sanitizeArchivePath(destPath, header.Name)
126+
if err != nil {
127+
return extractedFiles, err
128+
}
129+
130+
switch header.Typeflag {
131+
case tar.TypeDir:
132+
if err := os.MkdirAll(target, 0o755); err != nil {
133+
return extractedFiles, fmt.Errorf("failed to create directory: %w", err)
134+
}
135+
case tar.TypeReg:
136+
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
137+
return extractedFiles, fmt.Errorf("failed to create parent directory: %w", err)
138+
}
139+
f, err := os.Create(target)
140+
if err != nil {
141+
return extractedFiles, fmt.Errorf("failed to create file: %w", err)
142+
}
143+
if _, err := io.Copy(f, tr); err != nil {
144+
f.Close()
145+
return extractedFiles, fmt.Errorf("failed to write file: %w", err)
146+
}
147+
f.Close()
148+
extractedFiles = append(extractedFiles, target)
149+
}
150+
}
151+
152+
return extractedFiles, nil
153+
}
154+
155+
// extractZipArchive reads a zip stream and extracts regular files to destPath.
156+
// Since zip requires random access, the stream is buffered into memory first.
157+
// Symlinks are silently skipped.
158+
func extractZipArchive(reader io.Reader, destPath string) ([]string, error) {
159+
// zip.NewReader requires io.ReaderAt + size, so buffer the stream
160+
buf, err := io.ReadAll(reader)
161+
if err != nil {
162+
return nil, fmt.Errorf("failed to read zip stream: %w", err)
163+
}
164+
165+
zr, err := zip.NewReader(bytes.NewReader(buf), int64(len(buf)))
166+
if err != nil {
167+
return nil, fmt.Errorf("failed to open zip archive: %w", err)
168+
}
169+
170+
var extractedFiles []string
171+
172+
for _, f := range zr.File {
173+
// Skip symlinks (mode check)
174+
if f.FileInfo().Mode()&os.ModeSymlink != 0 {
175+
continue
176+
}
177+
178+
target, err := sanitizeArchivePath(destPath, f.Name)
179+
if err != nil {
180+
return extractedFiles, err
181+
}
182+
183+
if f.FileInfo().IsDir() {
184+
if err := os.MkdirAll(target, 0o755); err != nil {
185+
return extractedFiles, fmt.Errorf("failed to create directory: %w", err)
186+
}
187+
continue
188+
}
189+
190+
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
191+
return extractedFiles, fmt.Errorf("failed to create parent directory: %w", err)
192+
}
193+
194+
rc, err := f.Open()
195+
if err != nil {
196+
return extractedFiles, fmt.Errorf("failed to open zip entry: %w", err)
197+
}
198+
199+
outFile, err := os.Create(target)
200+
if err != nil {
201+
rc.Close()
202+
return extractedFiles, fmt.Errorf("failed to create file: %w", err)
203+
}
204+
205+
if _, err := io.Copy(outFile, rc); err != nil {
206+
outFile.Close()
207+
rc.Close()
208+
return extractedFiles, fmt.Errorf("failed to write file: %w", err)
209+
}
210+
211+
outFile.Close()
212+
rc.Close()
213+
extractedFiles = append(extractedFiles, target)
214+
}
215+
216+
return extractedFiles, nil
217+
}
218+
219+
// sanitizeArchivePath prevents zip-slip attacks by ensuring the resolved path
220+
// stays within the destination directory.
221+
func sanitizeArchivePath(destPath, entryName string) (string, error) {
222+
cleanName := filepath.FromSlash(entryName)
223+
target := filepath.Join(destPath, cleanName)
224+
225+
// Prevent zip-slip: ensure the target is within destPath
226+
rel, err := filepath.Rel(destPath, target)
227+
if err != nil {
228+
return "", fmt.Errorf("failed to resolve path: %w", err)
229+
}
230+
if strings.HasPrefix(rel, "..") {
231+
return "", fmt.Errorf("illegal path in archive (zip-slip): %s", entryName)
232+
}
233+
234+
return target, nil
235+
}
236+
237+
func init() {
238+
err := core.RegisterNodeFactory(fileDecompressDefinition, func(ctx any, parent core.NodeBaseInterface, parentId string, nodeDef map[string]any, validate bool, opts core.RunOpts) (core.NodeBaseInterface, []error) {
239+
return &FileDecompressNode{}, nil
240+
})
241+
if err != nil {
242+
panic(err)
243+
}
244+
}

0 commit comments

Comments
 (0)