diff --git a/internal/services/tasks/tiny_log.go b/internal/services/tasks/tiny_log.go index fe9f271..7755f5a 100644 --- a/internal/services/tasks/tiny_log.go +++ b/internal/services/tasks/tiny_log.go @@ -9,12 +9,18 @@ import ( "os" "path/filepath" "sync" + "unicode/utf8" "github.com/engigu/baihu-panel/internal/constant" "github.com/engigu/baihu-panel/internal/logger" "github.com/engigu/baihu-panel/internal/utils" ) +const ( + // maxLogBufferLen 定义了没有换行符时的最大缓冲长度 (4KB) + maxLogBufferLen = 4096 +) + var ( // globalTinyLogManager 跟踪所有活跃的 TinyLog 实例 globalTinyLogManager = &TinyLogManager{ @@ -98,12 +104,30 @@ func (l *TinyLog) Write(p []byte) (n int, err error) { l.remainder = nil } - // 1. 寻找最后一个换行符 - lastNewline := bytes.LastIndexByte(payload, '\n') - if lastNewline == -1 { - // 没有换行符,且如果长度超过 4KB,强制截断并输出,防止内存无限制增长 - if len(payload) > 4096 { - lastNewline = len(payload) - 1 + // 1. 寻找最后一个换行符 (\n 或 \r) + lastLineBreak := bytes.LastIndexAny(payload, "\n\r") + var completeBytes []byte + var remainder []byte + + if lastLineBreak != -1 { + // 2. 提取出完整的行 + completeBytes = payload[:lastLineBreak+1] + remainder = payload[lastLineBreak+1:] + } else { + // 3. 没有换行符,且如果长度超过最大缓冲,强制截断并输出,防止内存无限制增长 + if len(payload) > maxLogBufferLen { + // 寻找最后一个完整的 UTF-8 字符边界,避免乱码 + lastSafe := maxLogBufferLen + for i := maxLogBufferLen; i > 0 && i > maxLogBufferLen-4; i-- { + if utf8.RuneStart(payload[i-1]) { + if !utf8.FullRune(payload[i-1 : maxLogBufferLen]) { + lastSafe = i - 1 + } + break + } + } + completeBytes = payload[:lastSafe] + remainder = payload[lastSafe:] } else { // 保留当前所有内容到下一轮 l.remainder = payload @@ -111,20 +135,14 @@ func (l *TinyLog) Write(p []byte) (n int, err error) { } } - // 2. 提取出完整的行 - completeBytes := payload[:lastNewline+1] + // 4. 将剩余部分保存 + l.remainder = remainder - // 3. 剥离并保存剩余的部分 - if lastNewline+1 < len(payload) { - l.remainder = make([]byte, len(payload)-(lastNewline+1)) - copy(l.remainder, payload[lastNewline+1:]) - } - - // 4. 将完整行转换为 UTF-8 并脱敏 + // 5. 将完整行转换为 UTF-8 并脱敏 text := utils.MaskSecrets(utils.ToUTF8(completeBytes), l.masks) outData := []byte(text) - // 5. 输出安全部分 + // 6. 输出安全部分 _, err = l.writer.Write(outData) if err != nil { return 0, err diff --git a/internal/services/tasks/tiny_log_test.go b/internal/services/tasks/tiny_log_test.go new file mode 100644 index 0000000..d0f2f50 --- /dev/null +++ b/internal/services/tasks/tiny_log_test.go @@ -0,0 +1,104 @@ +package tasks + +import ( + "bytes" + "testing" +) + +func TestTinyLog_UTF8Splitting(t *testing.T) { + tl, err := NewTinyLog("test-utf8", nil) + if err != nil { + t.Fatalf("Failed to create TinyLog: %v", err) + } + defer tl.Close() + + // "你好" in UTF-8: E4 BD A0, E5 a5 bd + part1 := []byte{0xE4, 0xBD} // Partial "你" + part2 := []byte{0xA0, 0xE5, 0xA5} // Rest of "你", partial "好" + part3 := []byte{0xBD, '\n'} // Rest of "好", newline + + _, _ = tl.Write(part1) + if len(tl.remainder) != 2 { + t.Errorf("Expected remainder len 2, got %d", len(tl.remainder)) + } + + _, _ = tl.Write(part2) + // Currently it should collect both parts but still no newline, + // so remainder should be 5 bytes. + if len(tl.remainder) != 5 { + t.Errorf("Expected remainder len 5, got %d", len(tl.remainder)) + } + + _, _ = tl.Write(part3) + if len(tl.remainder) != 0 { + t.Errorf("Expected remainder len 0 after newline, got %d", len(tl.remainder)) + } + + // Read and verify + data, err := tl.ReadLastLines(1) + if err != nil { + t.Fatalf("ReadLastLines failed: %v", err) + } + if !bytes.Contains(data, []byte("你好")) { + t.Errorf("Expected output to contain '你好', got %q", data) + } +} + +func TestTinyLog_CarriageReturn(t *testing.T) { + tl, err := NewTinyLog("test-cr", nil) + if err != nil { + t.Fatalf("Failed to create TinyLog: %v", err) + } + defer tl.Close() + + input := []byte("progress: 50%\rprogress: 100%\r\n") + _, _ = tl.Write(input) + + data, err := tl.ReadLastLines(10) + if err != nil { + t.Fatalf("ReadLastLines failed: %v", err) + } + + // Should contain both progress lines (or at least be split correctly) + if !bytes.Contains(data, []byte("progress: 50%")) { + t.Errorf("Expected output to contain 'progress: 50%%', got %q", data) + } + if !bytes.Contains(data, []byte("progress: 100%")) { + t.Errorf("Expected output to contain 'progress: 100%%', got %q", data) + } +} + +func TestTinyLog_LongLineCut(t *testing.T) { + tl, err := NewTinyLog("test-long", nil) + if err != nil { + t.Fatalf("Failed to create TinyLog: %v", err) + } + defer tl.Close() + + // Create a buffer of maxLogBufferLen-1 bytes with a multi-byte character at the maxLogBufferLen boundary + // We want to ensure it doesn't cut in the middle of a 3-byte char. + longData := make([]byte, maxLogBufferLen-1) + for i := range longData { + longData[i] = 'A' + } + // "你" is E4 BD A0 + longData = append(longData, 0xE4, 0xBD, 0xA0) // This starts at index maxLogBufferLen-1. + // Index maxLogBufferLen-1: E4 + // Index maxLogBufferLen: BD + // Index maxLogBufferLen+1: A0 + // If we cut at maxLogBufferLen, we split E4 and BD. + + _, _ = tl.Write(longData) + + // Since it's > maxLogBufferLen and no newline, it should trigger the cut. + // Our logic finds the last safe boundary before maxLogBufferLen. + // RuneStart(E4) at maxLogBufferLen-1 is true. FullRune(E4 at maxLogBufferLen-1 in payload[:maxLogBufferLen]) is false. + // So lastSafe should be maxLogBufferLen-1. + + // The first maxLogBufferLen-1 bytes (all 'A') should be processed. + // The "你" should be in remainder. + + if len(tl.remainder) != 3 { + t.Errorf("Expected remainder len 3 (the char '你'), got %d", len(tl.remainder)) + } +}