// Package labelfile 解析使用者上傳的 label 檔(` <名稱>` 每行一筆), // 產出可直接注入 Python bridge 的密集 []string。 // // 為什麼是獨立 package:解析規則全是純函式、與 HTTP / driver / model 儲存都 // 無關,抽出來才能用大量 table-driven 測試把容錯規則逐條釘死(規則來源見 // plan-classification-inference.md §3.2,該表已定死、實作不得自行發揮)。 package labelfile import ( "bufio" "bytes" "fmt" "strconv" "strings" "unicode" "unicode/utf8" ) const ( // MaxIndex 是允許的最大 class index(含)。 // // 這是本功能最實際的 DoS 面(plan §3.5 / §7 R-7):解析結果要轉成密集 // []string,一行 `999999999 x` 就會要求配置約 8GB 的 slice header 空間。 // 4095 對真實模型綽綽有餘(ImageNet 1000 類、COCO 80 類)。 MaxIndex = 4095 // MaxLines 是允許的最大「有效內容行數」(不含空行與註解),與 MaxIndex // 互為雙保險:MaxIndex 擋單一巨大 index,MaxLines 擋「index 都合法但行數 // 爆量」。 // // 為什麼剛好是 MaxIndex+1 而不是更大的值:index 必須唯一且 <= MaxIndex, // 所以合法檔案最多就是 MaxIndex+1 行。設得比這更大會讓這道檢查永遠碰不到 // (重複 index 的檢查會先擋下),變成無效防禦 —— 有一個「看起來有防護但 // 其實跑不到」的常數,比沒有更糟。 MaxLines = MaxIndex + 1 // MaxFileSize 是允許的檔案大小上限(bytes)。 // 3 類 label 約 30 bytes;即使 4096 類中文標籤也遠低於 256 KB。 MaxFileSize = 256 * 1024 ) // Result 是一次成功解析的產物。 type Result struct { // Labels 是密集陣列:位置 = class index,稀疏處為空字串。 // // 為什麼用密集 []string 而非 map(plan §3.2 A1):Model.Labels 與 // FlashOptions.Labels 都已經是 []string,改成 map 要動 Go struct、TS type、 // models.json 全部既有 model 與 upload handler。Python 端 _resolve_label // 對空字串已會 fallback 回 class_N,稀疏語意天然成立。 Labels []string // LabelCount 是「實際有名稱的筆數」(不含補洞用的空字串)。 LabelCount int // MaxIndex 是檔案中出現過的最大 index,等於 len(Labels)-1。 MaxIndex int } // ParseError 帶行號的解析失敗。整檔拒絕(不做部分接受)—— 靜默略過壞行會讓 // 使用者拿到看似成功但標註錯位的結果,那比直接失敗糟得多。 type ParseError struct { // Line 是 1-based 行號;0 表示錯誤與特定行無關(如空檔、編碼問題)。 Line int // Reason 是給使用者看的說明。 Reason string } func (e *ParseError) Error() string { if e.Line > 0 { return fmt.Sprintf("第 %d 行:%s", e.Line, e.Reason) } return e.Reason } // utf8BOM 是 UTF-8 位元組順序標記。Windows 記事本另存 UTF-8 會加它, // 不 strip 的話第一行的 index token 會帶著 BOM 位元組而解析失敗。 var utf8BOM = []byte{0xEF, 0xBB, 0xBF} // Parse 解析 label 檔內容。 // // 容錯規則完全依照 plan §3.2 的表: // // 空行 / 純空白行 → 略過 // `#` 開頭 → 註解、略過 // 行尾 \r(CRLF) → trim // 名稱含空白 → 只 split 第一個空白,其餘全算名稱 // index 非整數 / 負數 → 整檔拒絕 + 行號 // index 重複 → 整檔拒絕 + 行號 // index 不連續 / 不從 0 開始 → 接受,缺的位置補空字串 // 空檔 / 全空行 → 拒絕 // 非 UTF-8 → 拒絕(BOM 先 strip) // 只有 index 沒名稱 → 拒絕 + 行號 func Parse(data []byte) (*Result, error) { if len(data) > MaxFileSize { return nil, &ParseError{ Reason: fmt.Sprintf("檔案過大(%d bytes),上限為 %d bytes", len(data), MaxFileSize), } } data = bytes.TrimPrefix(data, utf8BOM) if !utf8.Valid(data) { return nil, &ParseError{ Reason: "檔案不是有效的 UTF-8 編碼,請改存成 UTF-8 後再上傳", } } // byIndex 保留原始的稀疏語意,最後才展開成密集陣列 —— 先展開的話, // 「index 重複」與「index 不連續補洞」兩種情況會分不出來。 byIndex := make(map[int]string) maxIndex := -1 contentLines := 0 scanner := bufio.NewScanner(bytes.NewReader(data)) // 單行上限放寬到 64KB:預設 bufio 上限也是 64KB,但預設 buffer 只有 4KB // 起跳、長行會回 bufio.ErrTooLong 而不是我們自己的錯誤訊息。 scanner.Buffer(make([]byte, 0, 4096), 64*1024) lineNo := 0 for scanner.Scan() { lineNo++ line := strings.TrimRight(scanner.Text(), "\r") trimmed := strings.TrimSpace(line) if trimmed == "" { continue } if strings.HasPrefix(trimmed, "#") { continue } contentLines++ if contentLines > MaxLines { return nil, &ParseError{ Line: lineNo, Reason: fmt.Sprintf("標籤行數超過上限 %d", MaxLines), } } idx, name, err := parseLine(trimmed) if err != nil { err.Line = lineNo return nil, err } if _, dup := byIndex[idx]; dup { return nil, &ParseError{ Line: lineNo, Reason: fmt.Sprintf("index %d 重複出現", idx), } } byIndex[idx] = name if idx > maxIndex { maxIndex = idx } } if err := scanner.Err(); err != nil { return nil, &ParseError{Reason: fmt.Sprintf("讀取檔案失敗:%v", err)} } if len(byIndex) == 0 { return nil, &ParseError{Reason: "標籤檔沒有任何有效內容"} } labels := make([]string, maxIndex+1) for idx, name := range byIndex { labels[idx] = name } return &Result{ Labels: labels, LabelCount: len(byIndex), MaxIndex: maxIndex, }, nil } // parseLine 解析單行 ` <名稱>`。回傳的 ParseError 不帶 Line,由呼叫端補。 func parseLine(line string) (int, string, *ParseError) { // 只切第一個空白:`0 traffic light` 必須解析成 {0: "traffic light"}。 // strings.Fields 會把名稱也切碎,所以刻意用 IndexFunc 自己找分界。 sep := strings.IndexFunc(line, unicode.IsSpace) if sep < 0 { return 0, "", &ParseError{ Reason: fmt.Sprintf("缺少標籤名稱(只有 %q),格式應為 ` <名稱>`", line), } } idxToken := line[:sep] name := strings.TrimSpace(line[sep:]) if name == "" { return 0, "", &ParseError{ Reason: fmt.Sprintf("index %s 後面缺少標籤名稱", idxToken), } } idx, err := strconv.Atoi(idxToken) if err != nil { return 0, "", &ParseError{ Reason: fmt.Sprintf("index 必須為非負整數,收到 %q", idxToken), } } if idx < 0 { return 0, "", &ParseError{ Reason: fmt.Sprintf("index 必須為非負整數,收到 %q", idxToken), } } if idx > MaxIndex { return 0, "", &ParseError{ Reason: fmt.Sprintf("index %d 超過上限 %d", idx, MaxIndex), } } if bad, ok := findControlChar(name); ok { return 0, "", &ParseError{ Reason: fmt.Sprintf("標籤名稱含有不允許的控制字元(U+%04X)", bad), } } return idx, name, nil } // findControlChar 找出名稱中的控制字元。標籤會直接被渲染到前端 DOM 與 canvas, // 控制字元(含 U+202E 這類 bidi override)會造成顯示錯亂,一律拒絕。 // Tab 已在 TrimSpace / 分隔判斷階段處理掉,這裡不需特別放行。 func findControlChar(name string) (rune, bool) { for _, r := range name { if unicode.IsControl(r) || (r >= 0x202A && r <= 0x202E) || (r >= 0x2066 && r <= 0x2069) { return r, true } } return 0, false }