4d3a6a09a6
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(不再夾帶第二份完整程式)
159 lines
5.3 KiB
Go
159 lines
5.3 KiB
Go
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)
|
||
}
|
||
// 寫回去時空值不該污染 JSON(omitempty)
|
||
if err := m.Save(mp); err != nil {
|
||
t.Fatalf("存檔失敗: %v", err)
|
||
}
|
||
b, _ := os.ReadFile(mp)
|
||
if strings.Contains(string(b), "extracted_by") {
|
||
t.Errorf("空值不該寫進 JSON(omitempty),實得:\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)
|
||
}
|
||
}
|