Skip to content

Commit c5e3e2a

Browse files
authored
Merge pull request #9 from gruntwork-io/add-generated-files-check
Add generated files check
2 parents c1464cb + b201f81 commit c5e3e2a

File tree

14 files changed

+960
-27
lines changed

14 files changed

+960
-27
lines changed

api/generated_files.go

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
package api
2+
3+
import (
4+
"fmt"
5+
"log/slog"
6+
"net/http"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
11+
"github.com/gin-gonic/gin"
12+
)
13+
14+
// This file provides HTTP handlers for managing generated files in the output directory.
15+
// It handles checking if files already exist and deleting them when requested by the user,
16+
// with validation to ensure operations are safe and confined to the configured output path.
17+
18+
// outputDirInfo contains validated information about the output directory
19+
type outputDirInfo struct {
20+
absoluteOutputPath string
21+
fileCount int
22+
exists bool
23+
}
24+
25+
// HandleGeneratedFilesCheck returns a handler that checks if files exist in the output directory
26+
func HandleGeneratedFilesCheck(rawOutputPath string) gin.HandlerFunc {
27+
return func(c *gin.Context) {
28+
dirInfo, err := validateAndGetOutputDirectory(rawOutputPath)
29+
if err != nil {
30+
slog.Error("Failed to validate output directory", "error", err, "outputPath", rawOutputPath)
31+
c.JSON(http.StatusInternalServerError, gin.H{
32+
"error": "Failed to validate output directory",
33+
"details": err.Error(),
34+
})
35+
return
36+
}
37+
38+
c.JSON(http.StatusOK, GeneratedFilesCheckResponse{
39+
HasFiles: dirInfo.fileCount > 0,
40+
OutputPath: dirInfo.absoluteOutputPath,
41+
FileCount: dirInfo.fileCount,
42+
})
43+
}
44+
}
45+
46+
// HandleGeneratedFilesDelete returns a handler that deletes all files in the output directory
47+
func HandleGeneratedFilesDelete(rawOutputPath string) gin.HandlerFunc {
48+
return func(c *gin.Context) {
49+
dirInfo, err := validateAndGetOutputDirectory(rawOutputPath)
50+
if err != nil {
51+
slog.Error("Failed to validate output directory", "error", err, "outputPath", rawOutputPath)
52+
// Determine appropriate status code based on error type
53+
statusCode := http.StatusInternalServerError
54+
if strings.Contains(err.Error(), "output path is not valid") {
55+
statusCode = http.StatusForbidden
56+
}
57+
c.JSON(statusCode, gin.H{
58+
"error": "Failed to validate output directory",
59+
"details": err.Error(),
60+
})
61+
return
62+
}
63+
64+
if !dirInfo.exists {
65+
c.JSON(http.StatusOK, GeneratedFilesDeleteResponse{
66+
Success: true,
67+
DeletedCount: 0,
68+
Message: "Output directory does not exist, nothing to delete",
69+
})
70+
return
71+
}
72+
73+
// Delete all contents of the directory (but not the directory itself)
74+
err = deleteDirectoryContents(dirInfo.absoluteOutputPath)
75+
if err != nil {
76+
slog.Error("Failed to delete directory contents", "error", err, "path", dirInfo.absoluteOutputPath)
77+
c.JSON(http.StatusInternalServerError, gin.H{
78+
"error": "Failed to delete files",
79+
"details": err.Error(),
80+
})
81+
return
82+
}
83+
84+
slog.Info("Successfully deleted generated files", "path", dirInfo.absoluteOutputPath, "count", dirInfo.fileCount)
85+
c.JSON(http.StatusOK, GeneratedFilesDeleteResponse{
86+
Success: true,
87+
DeletedCount: dirInfo.fileCount,
88+
Message: fmt.Sprintf("Successfully deleted %d file(s) from %s", dirInfo.fileCount, dirInfo.absoluteOutputPath),
89+
})
90+
}
91+
}
92+
93+
// validateAndGetOutputDirectory validates the output path and retrieves its information.
94+
// Returns outputDirInfo and any error encountered.
95+
func validateAndGetOutputDirectory(rawOutputPath string) (*outputDirInfo, error) {
96+
// Resolve the output path to absolute
97+
absoluteOutputPath, err := resolveToAbsolutePath(rawOutputPath)
98+
if err != nil {
99+
return nil, fmt.Errorf("failed to resolve output path: %w", err)
100+
}
101+
102+
// Validate the output path
103+
if err := validateOutputPathSafety(absoluteOutputPath); err != nil {
104+
return nil, err
105+
}
106+
107+
// Check if directory exists
108+
info, err := os.Stat(absoluteOutputPath)
109+
if os.IsNotExist(err) {
110+
return &outputDirInfo{
111+
absoluteOutputPath: absoluteOutputPath,
112+
fileCount: 0,
113+
exists: false,
114+
}, nil
115+
}
116+
if err != nil {
117+
return nil, fmt.Errorf("failed to stat output directory: %w", err)
118+
}
119+
120+
// Verify it's a directory
121+
if !info.IsDir() {
122+
return nil, fmt.Errorf("path exists but is not a directory: %s", absoluteOutputPath)
123+
}
124+
125+
// Count files in the directory
126+
fileCount, err := countFilesInDirectory(absoluteOutputPath)
127+
if err != nil {
128+
return nil, fmt.Errorf("failed to count files: %w", err)
129+
}
130+
131+
return &outputDirInfo{
132+
absoluteOutputPath: absoluteOutputPath,
133+
fileCount: fileCount,
134+
exists: true,
135+
}, nil
136+
}
137+
138+
// resolveToAbsolutePath converts a file path to its absolute form.
139+
// Relative paths are resolved relative to the current working directory.
140+
// Absolute paths are returned unchanged. Returns an error if the path is empty.
141+
func resolveToAbsolutePath(rawPath string) (string, error) {
142+
if rawPath == "" {
143+
return "", fmt.Errorf("path cannot be empty")
144+
}
145+
146+
// If path is already absolute, return it
147+
if filepath.IsAbs(rawPath) {
148+
return rawPath, nil
149+
}
150+
151+
// Otherwise, make it relative to the current working directory
152+
currentDir, err := os.Getwd()
153+
if err != nil {
154+
return "", fmt.Errorf("failed to get current working directory: %w", err)
155+
}
156+
157+
return filepath.Join(currentDir, rawPath), nil
158+
}
159+
160+
// countFilesInDirectory counts all files (not directories) in a directory recursively
161+
func countFilesInDirectory(absolutePath string) (int, error) {
162+
count := 0
163+
164+
err := filepath.Walk(absolutePath, func(path string, info os.FileInfo, err error) error {
165+
if err != nil {
166+
return err
167+
}
168+
// Only count files, not directories
169+
if !info.IsDir() {
170+
count++
171+
}
172+
return nil
173+
})
174+
175+
if err != nil {
176+
return 0, fmt.Errorf("failed to walk directory: %w", err)
177+
}
178+
179+
return count, nil
180+
}
181+
182+
// deleteDirectoryContents deletes all files and subdirectories within a directory,
183+
// but preserves the directory itself
184+
func deleteDirectoryContents(absolutePath string) error {
185+
// Just to be sure, validate that the path is safe to delete
186+
if err := validateOutputPathSafety(absolutePath); err != nil {
187+
return fmt.Errorf("failed to validate output path as safe to delete: %w", err)
188+
}
189+
190+
entries, err := os.ReadDir(absolutePath)
191+
if err != nil {
192+
return fmt.Errorf("failed to read directory: %w", err)
193+
}
194+
195+
for _, entry := range entries {
196+
entryPath := filepath.Join(absolutePath, entry.Name())
197+
err := os.RemoveAll(entryPath)
198+
if err != nil {
199+
return fmt.Errorf("failed to delete %s: %w", entryPath, err)
200+
}
201+
}
202+
203+
return nil
204+
}
205+
206+
// validateOutputPathSafety checks if a path is valid for file operations
207+
// It prevents operations on system-critical directories and enforces that the path
208+
// must be within the current working directory
209+
func validateOutputPathSafety(absoluteOutputPath string) error {
210+
// Reject empty paths
211+
if absoluteOutputPath == "" {
212+
return fmt.Errorf("output path is not valid: path cannot be empty")
213+
}
214+
215+
// Get absolute path to ensure we're checking the real location
216+
absPath, err := filepath.Abs(absoluteOutputPath)
217+
if err != nil {
218+
return fmt.Errorf("output path is not valid: failed to get absolute path: %w", err)
219+
}
220+
221+
// Resolve symlinks to get the actual target path
222+
// This prevents attacks using symlinks pointing outside CWD
223+
resolvedPath, err := filepath.EvalSymlinks(absPath)
224+
if err != nil {
225+
// If the path doesn't exist yet, EvalSymlinks will fail
226+
// In that case, just use the absolute path
227+
resolvedPath = absPath
228+
}
229+
230+
// Clean the path to resolve any . or .. components
231+
cleanPath := filepath.Clean(resolvedPath)
232+
233+
// Get current working directory
234+
cwd, err := os.Getwd()
235+
if err != nil {
236+
return fmt.Errorf("output path is not valid: failed to get current working directory: %w", err)
237+
}
238+
239+
// The path must be within or equal to the current working directory
240+
// Use Rel to check if the path is under cwd
241+
rel, err := filepath.Rel(cwd, cleanPath)
242+
if err != nil {
243+
return fmt.Errorf("output path is not valid: failed to get relative path: %w", err)
244+
}
245+
246+
// If rel starts with "..", it's outside the current working directory
247+
if filepath.IsAbs(rel) || len(rel) >= 2 && rel[0] == '.' && rel[1] == '.' {
248+
return fmt.Errorf("output path is not valid: path must be within the current working directory (path: %s, cwd: %s)", cleanPath, cwd)
249+
}
250+
251+
// Additional safety checks for common system directories
252+
// Normalize path for comparison (use ToSlash for cross-platform comparison)
253+
normalizedPath := filepath.ToSlash(strings.ToLower(cleanPath))
254+
255+
dangerousPaths := []string{
256+
"/",
257+
"/bin",
258+
"/boot",
259+
"/dev",
260+
"/etc",
261+
"/home",
262+
"/lib",
263+
"/lib64",
264+
"/opt",
265+
"/proc",
266+
"/root",
267+
"/sbin",
268+
"/sys",
269+
"/usr",
270+
"/var",
271+
"c:/",
272+
"c:/windows",
273+
"c:/program files",
274+
"c:/program files (x86)",
275+
"c:/users",
276+
}
277+
278+
for _, dangerous := range dangerousPaths {
279+
if normalizedPath == dangerous {
280+
return fmt.Errorf("output path is not valid: cannot use system directory: %s", cleanPath)
281+
}
282+
}
283+
284+
return nil
285+
}
286+

0 commit comments

Comments
 (0)