Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions deploy/.goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ builds:
- id: preflight
main: ./cmd/preflight/main.go
env: [CGO_ENABLED=0]
goos: [linux, darwin]
goos: [linux, darwin, windows]
goarch: [amd64, arm, arm64]
ignore:
- goos: windows
Expand All @@ -31,7 +31,7 @@ builds:
- id: support-bundle
main: ./cmd/troubleshoot/main.go
env: [CGO_ENABLED=0]
goos: [linux, darwin]
goos: [linux, darwin, windows]
goarch: [amd64, arm, arm64]
ignore:
- goos: windows
Expand Down
20 changes: 15 additions & 5 deletions internal/specs/specs.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net/http"
"net/url"
"os"
"path/filepath"
"reflect"
"strings"
"time"
Expand Down Expand Up @@ -137,18 +138,27 @@ func LoadFromCLIArgs(ctx context.Context, client kubernetes.Interface, args []st
rawSpecs = append(rawSpecs, spec)
}
}
} else if _, err := os.Stat(v); err == nil {
} else if v == "-" {
b, err := io.ReadAll(os.Stdin)
if err != nil {
return nil, types.NewExitCodeError(constants.EXIT_CODE_CATCH_ALL, err)
}
rawSpecs = append(rawSpecs, string(b))
} else if filepath.IsAbs(v) {
// Check if it's an absolute path (handles both Unix and Windows paths)
// This must come before URL parsing because Windows paths like C:\...
// would be parsed as having scheme "c" by url.Parse
b, err := os.ReadFile(v)
if err != nil {
return nil, types.NewExitCodeError(constants.EXIT_CODE_SPEC_ISSUES, err)
}

rawSpecs = append(rawSpecs, string(b))
} else if v == "-" {
b, err := io.ReadAll(os.Stdin)
} else if _, err := os.Stat(v); err == nil {
b, err := os.ReadFile(v)
if err != nil {
return nil, types.NewExitCodeError(constants.EXIT_CODE_CATCH_ALL, err)
return nil, types.NewExitCodeError(constants.EXIT_CODE_SPEC_ISSUES, err)
}

rawSpecs = append(rawSpecs, string(b))
} else {
u, err := url.Parse(v)
Expand Down
28 changes: 26 additions & 2 deletions pkg/analyze/download.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"io/fs"
"os"
"path/filepath"
"runtime"

getter "github.com/hashicorp/go-getter"
"github.com/pkg/errors"
Expand Down Expand Up @@ -91,7 +92,21 @@ func DownloadAndAnalyze(bundleURL string, analyzersSpec string) ([]*AnalyzeResul
}

func DownloadAndExtractSupportBundle(bundleURL string) (string, string, error) {
tmpDir, err := os.MkdirTemp("", "troubleshoot-k8s")
// Windows-only: Use working directory to avoid antivirus file locking
// Linux/macOS: Use system temp (original behavior)
var tempDir string
if runtime.GOOS == "windows" {
cwd, err := os.Getwd()
if err != nil {
tempDir = "" // Fallback to system temp
} else {
tempDir = cwd
}
} else {
tempDir = "" // Linux/macOS: system temp (unchanged)
}

tmpDir, err := os.MkdirTemp(tempDir, "troubleshoot-k8s-")
if err != nil {
return "", "", errors.Wrap(err, "failed to create temp dir")
}
Expand Down Expand Up @@ -132,7 +147,16 @@ func downloadTroubleshootBundle(bundleURL string, destDir string) error {
return errors.Wrap(err, "failed to get workdir")
}

tmpDir, err := os.MkdirTemp("", "troubleshoot")
// Windows-only: Use working directory to avoid antivirus file locking
// Linux/macOS: Use system temp (original behavior)
var tempDir string
if runtime.GOOS == "windows" {
tempDir = pwd // Use working directory for Windows
} else {
tempDir = "" // Linux/macOS: system temp (unchanged)
}

tmpDir, err := os.MkdirTemp(tempDir, "troubleshoot-")
if err != nil {
return errors.Wrap(err, "failed to create tmp dir")
}
Expand Down
4 changes: 3 additions & 1 deletion pkg/collect/redact.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,9 @@ func RedactResult(bundlePath string, input CollectorResult, additionalRedactors
return
}

redacted, err := redact.Redact(reader, file, additionalRedactors)
// Normalize the path for redaction (convert Windows backslashes to forward slashes)
normalizedFile := filepath.ToSlash(file)
redacted, err := redact.Redact(reader, normalizedFile, additionalRedactors)
if err != nil {
errorCh <- errors.Wrap(err, "failed to redact io stream")
return
Expand Down
92 changes: 89 additions & 3 deletions pkg/collect/result.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import (
"os"
"path"
"path/filepath"
"runtime"
"strings"
"time"

"github.com/pkg/errors"
"k8s.io/klog/v2"
Expand Down Expand Up @@ -189,7 +191,20 @@ func (r CollectorResult) ReplaceResult(bundlePath string, relativePath string, r
}

// Create a temporary file in the same directory as the target file to prevent cross-device issues
tmpFile, err := os.CreateTemp("", "replace-")
var tmpFile *os.File
var err error

if runtime.GOOS == "windows" {
// Windows-only: Use destination directory to avoid antivirus issues
destDir := filepath.Dir(filepath.Join(bundlePath, relativePath))
if err := os.MkdirAll(destDir, 0755); err != nil {
return errors.Wrap(err, "failed to create destination directory")
}
tmpFile, err = os.CreateTemp(destDir, "replace-")
} else {
// Linux/macOS: EXACT original behavior - system temp
tmpFile, err = os.CreateTemp("", "replace-")
}
if err != nil {
return errors.Wrap(err, "failed to create temp file")
}
Expand All @@ -204,9 +219,21 @@ func (r CollectorResult) ReplaceResult(bundlePath string, relativePath string, r
tmpFile.Close()

// This rename should always be in /tmp, so no cross-partition copying will happen
err = os.Rename(tmpFile.Name(), filepath.Join(bundlePath, relativePath))
if runtime.GOOS == "windows" {
// Windows-specific handling with delete-first approach
finalPath := filepath.Join(bundlePath, relativePath)

// Delete target file first (Windows requirement to release locks)
os.Remove(finalPath)

// Use copy+delete instead of rename (more reliable on Windows)
err = copyFileWindows(tmpFile.Name(), finalPath)
} else {
// Linux/macOS: EXACT original behavior - DO NOT CHANGE
err = os.Rename(tmpFile.Name(), filepath.Join(bundlePath, relativePath))
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Windows File Replacement Fails Atomically

The Windows-specific file replacement logic is not atomic. It deletes the target file before copying the temporary file, which can lead to permanent data loss if the copy operation fails. The original os.Rename operation was atomic, preserving the file on failure. Additionally, the error message "failed to rename tmp file" is misleading on Windows, as the operation is now a copy and delete.

Fix in Cursor Fix in Web

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like bugbot is right about this to me, the file is removed and then its copied? Shouldnt it be copied before its deleted?

if err != nil {
return errors.Wrap(err, "failed to rename tmp file")
return errors.Wrap(err, "failed to replace file")
}

return nil
Expand Down Expand Up @@ -417,3 +444,62 @@ func TarSupportBundleDir(bundlePath string, input CollectorResult, outputFilenam
// Is this used anywhere external anyway?
return input.ArchiveBundle(bundlePath, outputFilename)
}

// copyFileWindows performs safer file replacement for Windows
// It writes to a temporary file first to avoid data loss if copy fails
func copyFileWindows(src, dst string) error {
// Step 1: Write to temp file with unique name (not touching original)
tmpDst := dst + ".tmp"

srcFile, err := os.Open(src)
if err != nil {
return err
}

tmpFile, err := os.Create(tmpDst)
if err != nil {
srcFile.Close()
return err
}

// Copy data to temp file
_, err = io.Copy(tmpFile, srcFile)

// Close both files explicitly
srcFile.Close()
tmpFile.Close()

if err != nil {
// Copy failed - original file is still intact!
os.Remove(tmpDst)
return err
}

// Step 2: Replace original with temp (with retry for file locking)
// Try up to 5 times with increasing delays for antivirus/locking issues
maxRetries := 5
for attempt := 0; attempt < maxRetries; attempt++ {
// Delete original to release locks
os.Remove(dst)

// Rename temp to final
err = os.Rename(tmpDst, dst)
if err == nil {
// Success! Clean up source
os.Remove(src)
return nil
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Windows File Copy Vulnerability

The copyFileWindows function introduces a data loss risk on Windows by deleting the original destination file before successfully renaming the new temporary file into its place. If the rename fails, the original file is permanently lost. It also fails to clean up the initial temporary source file if the replacement process fails.

Fix in Cursor Fix in Web

}

// If not last attempt, wait with exponential backoff
if attempt < maxRetries-1 {
delay := time.Duration(10*(attempt+1)) * time.Millisecond
time.Sleep(delay)
}
}

// All retries failed - clean up dst temp file but keep src
// We intentionally leave src (the source temp file) to prevent data loss
// It will be cleaned up when the parent temp directory is removed
os.Remove(tmpDst)
return errors.Wrap(err, "failed to replace file after retries")
}
11 changes: 8 additions & 3 deletions pkg/redact/redact.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bufio"
"fmt"
"io"
"path/filepath"
"regexp"
"sync"

Expand Down Expand Up @@ -170,30 +171,34 @@ func buildAdditionalRedactors(path string, redacts []*troubleshootv1beta2.Redact
}

func redactMatchesPath(path string, redact *troubleshootv1beta2.Redact) (bool, error) {
normalizedPath := filepath.ToSlash(path)

if redact.FileSelector.File == "" && len(redact.FileSelector.Files) == 0 {
return true, nil
}

globs := []glob.Glob{}

if redact.FileSelector.File != "" {
newGlob, err := glob.Compile(redact.FileSelector.File, '/')
normalizedGlob := filepath.ToSlash(redact.FileSelector.File)
newGlob, err := glob.Compile(normalizedGlob, '/')
if err != nil {
return false, errors.Wrapf(err, "invalid file glob string %q", redact.FileSelector.File)
}
globs = append(globs, newGlob)
}

for i, fileGlobString := range redact.FileSelector.Files {
newGlob, err := glob.Compile(fileGlobString, '/')
normalizedGlob := filepath.ToSlash(fileGlobString)
newGlob, err := glob.Compile(normalizedGlob, '/')
if err != nil {
return false, errors.Wrapf(err, "invalid file glob string %d %q", i, fileGlobString)
}
globs = append(globs, newGlob)
}

for _, thisGlob := range globs {
if thisGlob.Match(path) {
if thisGlob.Match(normalizedPath) {
return true, nil
}
}
Expand Down
28 changes: 16 additions & 12 deletions pkg/redact/yaml.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bufio"
"bytes"
"io"
"path"
"path/filepath"
"strconv"
"strings"
Expand All @@ -22,20 +23,22 @@ type YamlRedactor struct {

func NewYamlRedactor(yamlPath, filePath, name string) *YamlRedactor {
pathComponents := strings.Split(yamlPath, ".")
return &YamlRedactor{maskPath: pathComponents, filePath: filePath, redactName: name}
return &YamlRedactor{maskPath: pathComponents, filePath: filepath.ToSlash(filePath), redactName: name}
}

func (r *YamlRedactor) Redact(input io.Reader, path string) io.Reader {
func (r *YamlRedactor) Redact(input io.Reader, targetPath string) io.Reader {
if r.filePath != "" {
match, err := filepath.Match(r.filePath, path)
normalizedTarget := filepath.ToSlash(targetPath)
match, err := path.Match(r.filePath, normalizedTarget)
if err != nil {
klog.Errorf("Failed to match %q and %q: %v", r.filePath, path, err)
klog.Errorf("Failed to match %q and %q: %v", r.filePath, normalizedTarget, err)
return input
}
if !match {
return input
}
}
r.foundMatch = false
reader, writer := io.Pipe()
go func() {
var err error
Expand All @@ -59,7 +62,8 @@ func (r *YamlRedactor) Redact(input io.Reader, path string) io.Reader {
return
}

newYaml := r.redactYaml(yamlInterface, r.maskPath)
processedPath := filepath.ToSlash(targetPath)
newYaml := r.redactYaml(yamlInterface, r.maskPath, processedPath)
if !r.foundMatch {
// no match found, so make no changes
buf := bytes.NewBuffer(doc)
Expand All @@ -80,14 +84,14 @@ func (r *YamlRedactor) Redact(input io.Reader, path string) io.Reader {
RedactorName: r.redactName,
CharactersRemoved: len(doc) - len(newBytes),
Line: 0, // line 0 because we have no way to tell what line was impacted
File: path,
File: targetPath,
IsDefaultRedactor: r.isDefault,
})
}()
return reader
}

func (r *YamlRedactor) redactYaml(in interface{}, path []string) interface{} {
func (r *YamlRedactor) redactYaml(in interface{}, path []string, targetPath string) interface{} {
if len(path) == 0 {
r.foundMatch = true

Expand All @@ -97,7 +101,7 @@ func (r *YamlRedactor) redactYaml(in interface{}, path []string) interface{} {
// Convert the value to string and tokenize it
if valueStr, ok := in.(string); ok && valueStr != "" {
context := r.redactName
return tokenizer.TokenizeValueWithPath(valueStr, context, r.filePath)
return tokenizer.TokenizeValueWithPath(valueStr, context, targetPath)
}
}

Expand All @@ -109,7 +113,7 @@ func (r *YamlRedactor) redactYaml(in interface{}, path []string) interface{} {
if path[0] == "*" {
var newArr []interface{}
for _, child := range typed {
newChild := r.redactYaml(child, path[1:])
newChild := r.redactYaml(child, path[1:], targetPath)
newArr = append(newArr, newChild)
}
return newArr
Expand All @@ -121,22 +125,22 @@ func (r *YamlRedactor) redactYaml(in interface{}, path []string) interface{} {
}
if len(typed) > pathIdx {
child := typed[pathIdx]
typed[pathIdx] = r.redactYaml(child, path[1:])
typed[pathIdx] = r.redactYaml(child, path[1:], targetPath)
return typed
}
return typed
case map[interface{}]interface{}:
if path[0] == "*" && len(typed) > 0 {
newMap := map[interface{}]interface{}{}
for key, child := range typed {
newMap[key] = r.redactYaml(child, path[1:])
newMap[key] = r.redactYaml(child, path[1:], targetPath)
}
return newMap
}

child, ok := typed[path[0]]
if ok {
newChild := r.redactYaml(child, path[1:])
newChild := r.redactYaml(child, path[1:], targetPath)
typed[path[0]] = newChild
}
return typed
Expand Down