package redact import ( "bytes" "io" "regexp" ) // lineState represents a line and whether it ended with a newline character. // This is used by MultiLineRedactor to track newline state for line pairs. type lineState struct { content []byte hadNewline bool } type MultiLineRedactor struct { scan *regexp.Regexp re1 *regexp.Regexp re2 *regexp.Regexp maskText string filePath string redactName string isDefault bool } func NewMultiLineRedactor(re1 LineRedactor, re2 string, maskText, path, name string, isDefault bool) (*MultiLineRedactor, error) { var scanCompiled *regexp.Regexp compiled1, err := compileRegex(re1.regex) if err != nil { return nil, err } if re1.scan != "" { scanCompiled, err = compileRegex(re1.scan) if err != nil { return nil, err } } compiled2, err := compileRegex(re2) if err != nil { return nil, err } return &MultiLineRedactor{scan: scanCompiled, re1: compiled1, re2: compiled2, maskText: maskText, filePath: path, redactName: name, isDefault: isDefault}, nil } // Redact processes the input reader in pairs of lines, applying redaction patterns. // Unlike the previous implementation using bufio.Reader with readLine(), this now // uses LineReader to preserve the exact newline structure of the input file. // // The MultiLineRedactor works by: // 1. Reading pairs of lines (line1, line2) // 2. If line1 matches the selector pattern (re1), redact line2 using re2 // 3. Write both lines with their original newline structure preserved // // This ensures binary files and text files without trailing newlines are not corrupted. func (r *MultiLineRedactor) Redact(input io.Reader, path string) io.Reader { out, writer := io.Pipe() go func() { var err error defer func() { writer.CloseWithError(err) }() tokenizer := GetGlobalTokenizer() lineReader := NewLineReader(input) // Try to read first two lines line1, nl1, line2, nl2, readErr := getNextTwoLines(lineReader, nil) // Handle case where we can't read 2 lines (empty file or single line) // Note: We check line1 == nil (not len(line1) == 0) because: // - nil means truly empty file with no content // - []byte{} (len==0) means an empty line that had a newline (e.g., "\n") if readErr != nil && line1 == nil { // Empty file - nothing to write // Propagate non-EOF errors (EOF is expected for empty files) if readErr != io.EOF { err = readErr } return } if readErr != nil { // Only 1 line available (or empty line with newline) - write it and exit // FIX: This is the bug fix - only add newline if original had one // Also handles empty lines (line1 == []byte{} with nl1 == true) err = writeLine(writer, line1, nl1) if err != nil { return } // Propagate non-EOF errors (EOF is expected for single-line files) if readErr != io.EOF { err = readErr } return } // Process line pairs flushLastLine := false lineNum := 1 for readErr == nil { lineNum++ // the first line that can be redacted is line 2 // Pre-filter: if scan is not nil, check if line1 matches scan by lowercasing it if r.scan != nil { lowerLine1 := bytes.ToLower(line1) if !r.scan.Match(lowerLine1) { // No match - write line1 and advance err = writeLine(writer, line1, nl1) if err != nil { return } line1, nl1, line2, nl2, readErr = getNextTwoLines(lineReader, &lineState{line2, nl2}) flushLastLine = true continue } } // Check if line1 matches the selector pattern (re1) if !r.re1.Match(line1) { // No match - write line1 and advance err = writeLine(writer, line1, nl1) if err != nil { return } line1, nl1, line2, nl2, readErr = getNextTwoLines(lineReader, &lineState{line2, nl2}) flushLastLine = true continue } // line1 matched selector - redact line2 flushLastLine = false var clean []byte if tokenizer.IsEnabled() { // Use tokenized replacement for line2 based on line1 context context := r.redactName clean = getTokenizedReplacementPatternWithPath(r.re2, line2, context, r.filePath) } else { // Use original masking behavior substStr := []byte(getReplacementPattern(r.re2, r.maskText)) clean = r.re2.ReplaceAll(line2, substStr) } // Write line1 (selector line) and line2 (redacted line) err = writeLine(writer, line1, nl1) if err != nil { return } err = writeLine(writer, clean, nl2) if err != nil { return } // Track redaction if content changed if !bytes.Equal(clean, line2) { addRedaction(Redaction{ RedactorName: r.redactName, CharactersRemoved: len(line2) - len(clean), Line: lineNum, File: r.filePath, IsDefaultRedactor: r.isDefault, }) } // Get next pair line1, nl1, line2, nl2, readErr = getNextTwoLines(lineReader, nil) } // After loop exits (readErr != nil), check if we have an unwritten line1 // This happens in two cases: // 1. flushLastLine=true: line1 was advanced but not written (scan/re1 didn't match) // 2. line1 != nil: we read line1 but couldn't get line2 (unpaired line at end) // Note: We check line1 != nil (not len(line1) > 0) to handle empty lines ([]byte{}) if flushLastLine || line1 != nil { err = writeLine(writer, line1, nl1) if err != nil { return } } // Propagate non-EOF read errors to the caller // EOF is expected (end of file) and not an error condition // Note: readErr is always non-nil here (loop exited), but we only propagate non-EOF errors if readErr != io.EOF { err = readErr } }() return out } // getNextTwoLines reads the next pair of lines from the LineReader. // It returns the content and newline state for both lines. // // If curLine2 is provided, it's used as line1 (optimization for advancing through file). // Otherwise, both lines are read fresh from the reader. // // Returns: // - line1, hadNewline1: First line content and newline state // - line2, hadNewline2: Second line content and newline state // - err: Error only if we couldn't read line1, or if line2 read failed with non-EOF error // // Note: If line2 returns (content, false, io.EOF), we treat this as SUCCESS because // we got the content. The EOF just means it didn't have a trailing newline. func getNextTwoLines(lr *LineReader, curLine2 *lineState) ( line1 []byte, hadNewline1 bool, line2 []byte, hadNewline2 bool, err error, ) { if curLine2 == nil { // Read both lines fresh line1, hadNewline1, err = lr.ReadLine() if err != nil { return } line2, hadNewline2, err = lr.ReadLine() // If we got line2 content but hit EOF, that's OK - it just means no trailing newline if err == io.EOF && len(line2) > 0 { err = nil // Clear the error - we successfully read both lines } return } // Use cached line2 as new line1 (optimization) line1 = curLine2.content hadNewline1 = curLine2.hadNewline // Read new line2 line2, hadNewline2, err = lr.ReadLine() // If we got line2 content but hit EOF, that's OK - it just means no trailing newline if err == io.EOF && len(line2) > 0 { err = nil // Clear the error - we successfully read both lines } return } // writeLine writes a line to the writer, optionally adding a newline if hadNewline is true. // This helper reduces code duplication in the Redact function. func writeLine(w io.Writer, line []byte, hadNewline bool) error { if err := writeBytes(w, line); err != nil { return err } if hadNewline { if err := writeBytes(w, NEW_LINE); err != nil { return err } } return nil } // writeBytes writes all byte slices to the writer // in the order they are passed in the variadic argument func writeBytes(w io.Writer, bs ...[]byte) error { for _, b := range bs { _, err := w.Write(b) if err != nil { return err } } return nil }