Files
arcrun-collector/manifest_extractedby_test.go
Leo 4d3a6a09a6 v0.18.9:collector 併進同一支執行檔——磁碟上不再攤出第二支 exe
leo 08-06 裁決:「不要兩支,寫成一支檔案」。

## 為什麼
v0.18.7-8 的「單一 exe」其實是**一支包著另一支**:collector.exe 被 go:embed
進 Arcrun.exe,執行時攤到 ~/.arcrun-rag/bin/ 再跑。
那正是防毒軟體眼中的 dropper 特徵 —— 封測者實撞
`Trojan:Win32/Sabsik.FL.A!ml`,檔案當場被隔離、自動刪除。

⚠️ 誠實界定:`!ml` 結尾=**機器學習判定**,Sabsik 是最常見的通用誤判家族,
   主因是「未簽章+下載次數少」,**不是**特別指向 dropper 行為。
   所以本次改動**不保證**解除誤判——真正的解是上架 MS Store(微軟簽章)。
   但「執行時把第二支 PE 寫到磁碟再執行」本來就該拿掉,這是對的方向且順手變小。

## 怎麼做
- `collector/` 39 個檔 `package main` → `package collector`,`main()` → 匯出的 `Run(args) int`
- 新增 `collector/cmd/collector/`(薄殼 CLI,讓單獨跑 collector 這條路仍可用)
- App 直接 import 該套件;`main()` 第一件事就判 `--collector`,是的話走 `collector.Run` 不碰 GUI
- `supervisor` 加 `ArgPrefix`,App 把 `BinPath` 指向 `os.Executable()` 自己
- 刪掉 `bundled_collector_{windows,other}.go`(embed + 攤檔那套)
- 三支打包腳本不再編/複製第二支;版本注入同時打到兩個 package
- build-win.sh 的機械閘改成**直接問它**:`--collector --version` 回得出版本才放行
  (舊閘是比大小,只能證明「有 embed」,證明不了「分派是對的」)

## 驗(真機實跑)
· `.app/Contents/MacOS/` 只有 **一個** 執行檔(原本兩個)
· 跑起來兩個行程是**同一個 exe**:
    …/MacOS/arcrun-app
    …/MacOS/arcrun-app --collector direct --config …
· `~/.arcrun-rag/bin` **不存在**(沒有任何東西被攤出來)
· 端到端:丟檔進看守資料夾 → collector.log `"status":"ingested","http_status":200`
· `lsappinfo` 仍是 `type="UIElement"`、`Version="0.18.9"`
· collector 39 檔測試全過;app 測試過;go vet 全綠;mac + windows 交叉編譯皆過
· 單檔 26MB → 22MB(不再夾帶第二份完整程式)
2026-08-06 16:00:47 +08:00

159 lines
5.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package collector
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// 這一組回答 leo 2026-07-27 的三個問題,用測試把「現在的實際行為」釘死,
// 免得下次再靠讀碼推論:
// ① 已經萃過了它知道嗎? → 知道,靠 ingested_hash
// ② gemma 會重萃 claude 萃過的嗎?→ 不會重萃(hash 相同就跳過),但原本分辨不出誰萃的
// ③ 誰負責萃? → cfg.Extractor 一個資料夾一個設定,無自動判斷
func mfEntry(t *testing.T, m *Manifest, path string) *ManifestEntry {
t.Helper()
e, ok := m.Entries[path]
if !ok {
t.Fatalf("manifest 缺 %s", path)
}
return e
}
// ① 萃過就記得:ingested_hash 與 content_hash 相同 → 下輪 Scan 不產生事件
func TestExtractedBy_萃過就不重萃(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "a.md")
os.WriteFile(p, []byte("內容一"), 0o644)
m := &Manifest{FolderID: "f", Entries: map[string]*ManifestEntry{}}
first, err := Scan(dir, m, ScanOptions{})
if err != nil {
t.Fatalf("首輪 scan 失敗: %v", err)
}
if len(first.Events) != 1 {
t.Fatalf("首輪應有 1 個 added,實得 %d", len(first.Events))
}
// 模擬萃取成功回寫
m.MarkIngestedBy("a.md", first.Events[0].SourceHash, 100, "gemma")
second, err := Scan(dir, m, ScanOptions{})
if err != nil {
t.Fatalf("次輪 scan 失敗: %v", err)
}
if len(second.Events) != 0 {
t.Errorf("內容沒改就不該再萃,實得事件: %+v", second.Events)
}
}
// ① 反面:內容改了就要重萃
func TestExtractedBy_內容改了要重萃(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "a.md")
os.WriteFile(p, []byte("內容一"), 0o644)
m := &Manifest{FolderID: "f", Entries: map[string]*ManifestEntry{}}
first, _ := Scan(dir, m, ScanOptions{})
m.MarkIngestedBy("a.md", first.Events[0].SourceHash, 100, "gemma")
os.WriteFile(p, []byte("內容二(改過)"), 0o644)
second, err := Scan(dir, m, ScanOptions{})
if err != nil {
t.Fatalf("scan 失敗: %v", err)
}
if len(second.Events) != 1 || second.Events[0].Type != "modified" {
t.Errorf("內容改了應產生 modified,實得: %+v", second.Events)
}
}
// ① 失敗不回寫 → 下輪自動重試(避免漏檔)
func TestExtractedBy_萃取失敗下輪要重試(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "a.md"), []byte("內容"), 0o644)
m := &Manifest{FolderID: "f", Entries: map[string]*ManifestEntry{}}
Scan(dir, m, ScanOptions{}) // 首輪偵測到,但「萃取失敗」→ 不呼叫 MarkIngested
second, err := Scan(dir, m, ScanOptions{})
if err != nil {
t.Fatalf("scan 失敗: %v", err)
}
if len(second.Events) != 1 {
t.Errorf("上輪未成功者下輪應重試,實得: %+v", second.Events)
}
}
// ② 核心:要分辨得出誰萃的
func TestExtractedBy_記錄萃取器(t *testing.T) {
m := &Manifest{FolderID: "f", Entries: map[string]*ManifestEntry{
"a.md": {ContentHash: "h1"},
"b.md": {ContentHash: "h2"},
}}
m.MarkIngestedBy("a.md", "h1", 100, "claude")
m.MarkIngestedBy("b.md", "h2", 100, "gemma")
if got := mfEntry(t, m, "a.md").ExtractedBy; got != "claude" {
t.Errorf("a.md 應記 claude,實得 %q", got)
}
if got := mfEntry(t, m, "b.md").ExtractedBy; got != "gemma" {
t.Errorf("b.md 應記 gemma,實得 %q", got)
}
}
// 舊簽名不可被破壞(多處呼叫,含 sync 路的 MarkIngestedEvents
func TestExtractedBy_舊MarkIngested仍可用(t *testing.T) {
m := &Manifest{FolderID: "f", Entries: map[string]*ManifestEntry{"a.md": {ContentHash: "h"}}}
if !m.MarkIngested("a.md", "h", 1) {
t.Fatal("舊簽名應仍可用")
}
if got := mfEntry(t, m, "a.md").ExtractedBy; got != "" {
t.Errorf("無萃取器路徑應留空,實得 %q", got)
}
}
// 向後相容:舊 manifest 沒有 extracted_by 欄位,讀進來不可爆
func TestExtractedBy_舊manifest讀得進來(t *testing.T) {
dir := t.TempDir()
mp := filepath.Join(dir, "manifest.json")
old := `{"folder_id":"f","root":"/x","entries":{"a.md":{"content_hash":"h","size":1,"mtime":2,"ingested_hash":"h","ingested_at":3}}}`
os.WriteFile(mp, []byte(old), 0o644)
m, err := LoadManifest(mp, dir)
if err != nil {
t.Fatalf("舊 manifest 應讀得進來: %v", err)
}
if got := mfEntry(t, m, "a.md").ExtractedBy; got != "" {
t.Errorf("舊資料應為空字串(未知),實得 %q", got)
}
// 寫回去時空值不該污染 JSONomitempty
if err := m.Save(mp); err != nil {
t.Fatalf("存檔失敗: %v", err)
}
b, _ := os.ReadFile(mp)
if strings.Contains(string(b), "extracted_by") {
t.Errorf("空值不該寫進 JSONomitempty),實得:\n%s", b)
}
}
// 有值時要真的存得進 JSON(不能只活在記憶體)
func TestExtractedBy_有值要存得進檔案(t *testing.T) {
dir := t.TempDir()
mp := filepath.Join(dir, "manifest.json")
m := &Manifest{FolderID: "f", Root: dir, Entries: map[string]*ManifestEntry{"a.md": {ContentHash: "h"}}}
m.MarkIngestedBy("a.md", "h", 100, "claude")
if err := m.Save(mp); err != nil {
t.Fatalf("存檔失敗: %v", err)
}
var back Manifest
b, _ := os.ReadFile(mp)
if err := json.Unmarshal(b, &back); err != nil {
t.Fatalf("讀回失敗: %v", err)
}
if back.Entries["a.md"].ExtractedBy != "claude" {
t.Errorf("extracted_by 應存進檔案,實得 %q", back.Entries["a.md"].ExtractedBy)
}
}