Files
a9d2180dd6 102 redactor newline corruption clean (#1947)
* fix: prevent redactors from corrupting binary files (#102)

Redactors were adding newlines to files without them, corrupting binary
files during support bundle collection (51 bytes → 53 bytes).

Created LineReader to track original newline state and only restore
newlines when they were present in the original file.

- Added pkg/redact/line_reader.go
- Refactored single_line.go, multi_line.go, literal.go
- Added 48 tests, all passing
- Verified: binary files now preserved byte-for-byte

Fixes #102


* fix: handle empty lines correctly in MultiLineRedactor

- Check line1 == nil instead of len(line1) == 0 for empty file detection
- Fixes edge case where file containing only '\n' would be dropped
- Addresses bugbot finding about empty line handling


* fix: handle empty lines correctly in MultiLineRedactor

- Check line1 != nil instead of len(line1) > 0 in both locations
- Fixes edge case where empty trailing lines would be dropped
- Fix test isolation in literal_test.go (move ResetRedactionList to parent)
- Addresses bugbot findings about empty line handling

* fmt

* chore: update regression baselines from run 20107431959

* adding defense

* fix: propagate non-EOF errors in all early return paths

Ensure non-EOF errors (like buffer overflow) are properly propagated
to caller in both pre-loop early returns. Addresses bugbot finding.

* fix: use unique test names to prevent redaction list pollution

Use t.Name() instead of hardcoded 'test' to ensure each test
has unique redactor name, preventing parallel test interference

---------

Co-authored-by: hedge-sparrow <sparrow@spooky.academy>
2025-12-10 16:55:54 -06:00

157 lines
4.0 KiB
Go

package redact
import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
"regexp"
"github.com/replicatedhq/troubleshoot/pkg/constants"
"k8s.io/klog/v2"
)
type SingleLineRedactor struct {
scan *regexp.Regexp
re *regexp.Regexp
maskText string
filePath string
redactName string
isDefault bool
}
var NEW_LINE = []byte{'\n'}
func NewSingleLineRedactor(re LineRedactor, maskText, path, name string, isDefault bool) (*SingleLineRedactor, error) {
var scanCompiled *regexp.Regexp
compiled, err := compileRegex(re.regex)
if err != nil {
return nil, err
}
if re.scan != "" {
scanCompiled, err = compileRegex(re.scan)
if err != nil {
return nil, err
}
}
return &SingleLineRedactor{scan: scanCompiled, re: compiled, maskText: maskText, filePath: path, redactName: name, isDefault: isDefault}, nil
}
// Redact processes the input reader line-by-line, applying redaction patterns.
// Unlike the previous implementation using bufio.Scanner, this now uses LineReader
// to preserve the exact newline structure of the input file. Lines that originally
// ended with \n will have \n added back, while lines without \n (like the last line
// of a file without a trailing newline, or binary files) will not have \n added.
// This ensures binary files and text files without trailing newlines are not corrupted.
func (r *SingleLineRedactor) Redact(input io.Reader, path string) io.Reader {
out, writer := io.Pipe()
go func() {
var err error
defer func() {
if err == nil || err == io.EOF {
writer.Close()
} else {
// Check if error is about line exceeding maximum size
if errors.Is(err, bufio.ErrTooLong) {
s := fmt.Sprintf("Error redacting %q. A line in the file exceeded %d MB max length", path, constants.SCANNER_MAX_SIZE/1024/1024)
klog.V(2).Info(s)
} else {
klog.V(2).Info(fmt.Sprintf("Error redacting %q: %v", path, err))
}
writer.CloseWithError(err)
}
}()
// Use LineReader instead of bufio.Scanner to track newline presence
lineReader := NewLineReader(input)
tokenizer := GetGlobalTokenizer()
lineNum := 0
for {
line, hadNewline, readErr := lineReader.ReadLine()
// Handle EOF with no content - we're done
if readErr == io.EOF && len(line) == 0 {
break
}
// We have content to process
lineNum++
// Determine if we should redact this line
shouldRedact := true
// Pre-filter: if scan is not nil, check if line matches scan by lowercasing it
if r.scan != nil {
lowerLine := bytes.ToLower(line)
if !r.scan.Match(lowerLine) {
shouldRedact = false
}
}
// Check if line matches the main redaction pattern
if shouldRedact && !r.re.Match(line) {
shouldRedact = false
}
// Process the line (redact or pass through)
var outputLine []byte
if shouldRedact {
// Line matches - perform redaction
if tokenizer.IsEnabled() {
// Use tokenized replacement - context comes from the redactor name
context := r.redactName
outputLine = getTokenizedReplacementPatternWithPath(r.re, line, context, r.filePath)
} else {
// Use original masking behavior
substStr := []byte(getReplacementPattern(r.re, r.maskText))
outputLine = r.re.ReplaceAll(line, substStr)
}
// Track redaction if content changed
if !bytes.Equal(outputLine, line) {
addRedaction(Redaction{
RedactorName: r.redactName,
CharactersRemoved: len(line) - len(outputLine),
Line: lineNum,
File: r.filePath,
IsDefaultRedactor: r.isDefault,
})
}
} else {
// No match - use original line
outputLine = line
}
// Write the line
err = writeBytes(writer, outputLine)
if err != nil {
return
}
// Only add newline if original line had one
if hadNewline {
err = writeBytes(writer, NEW_LINE)
if err != nil {
return
}
}
// Check if we hit EOF after processing this line
if readErr == io.EOF {
break
}
// Check for non-EOF errors
if readErr != nil {
err = readErr
return
}
}
}()
return out
}