Files
arcrun-collector/scan_skipped_test.go
T
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

142 lines
5.3 KiB
Go
Raw 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
// scan_skipped_test.go — G-6.2「不准安靜地略過」(J-1/S62026-08-06)。
//
// 考題:「Given 我丟進去的是 PDF 或 Word / When 我搜它的內容 /
// Then 我一樣找得到——**或當場被告知這種檔案還不支援**,不准安靜地略過。」
//
// 改動前的實測基線(真檔跑過):把 .doc/.key/.jpg 丟進資料夾,`collector scan`
// 吐出的 events 只有 .pdf 與 .md,另外三個檔**沒有在任何輸出裡留下一個字**。
// 本檔把「那三個檔要留下名字」釘住。
import (
"encoding/json"
"strings"
"testing"
)
// 讀不了的文件要逐檔點名;純粹不是文件的只計數;**能處理的檔完全不受影響**(回歸)。
func TestScanReportsSkippedFiles(t *testing.T) {
root := t.TempDir()
// 支援的(必須照常產生事件)
writeFile(t, root, "筆記.md", "# 內容", baseTime)
writeFile(t, root, "報表.csv", "a,b\n1,2", baseTime)
// 像文件、但還讀不了的(必須逐檔點名)
writeFile(t, root, "舊版報告.doc", "\xd0\xcf\x11\xe0binary", baseTime)
writeFile(t, root, "提案/簡報.key", "binary", baseTime)
// 不是文件的(只計數,不點名——避免附件庫炸出幾百行噪音)
writeFile(t, root, "照片.jpg", "\xff\xd8\xff", baseTime)
writeFile(t, root, "封存.zip", "PK", baseTime)
m := newTestManifest()
p := mustScan(t, root, m)
// ① 回歸:支援的格式照舊,一個不多一個不少。
added := eventsOfType(p, "added")
if len(added) != 2 {
t.Fatalf("支援的檔應產生 2 個 added 事件,實得 %d%+v", len(added), added)
}
for _, ev := range added {
if strings.HasSuffix(ev.Path, ".doc") || strings.HasSuffix(ev.Path, ".key") ||
strings.HasSuffix(ev.Path, ".jpg") || strings.HasSuffix(ev.Path, ".zip") {
t.Fatalf("讀不了的檔不該被送進管線(會被當二進位餵給模型):%s", ev.Path)
}
}
// ② 讀不了的文件必須留名——這就是「不准安靜地略過」。
if len(p.Skipped) != 2 {
t.Fatalf("應點名 2 個讀不了的文件,實得 %d:%+v", len(p.Skipped), p.Skipped)
}
got := map[string]string{}
for _, s := range p.Skipped {
got[s.Path] = s.Ext
}
if got["舊版報告.doc"] != ".doc" {
t.Errorf("舊版報告.doc 沒被點名:%+v", got)
}
if got["提案/簡報.key"] != ".key" {
t.Errorf("子目錄裡的 .key 沒被點名(相對路徑要保留):%+v", got)
}
// ③ 非文件檔只給總數。
if p.SkippedOther != 2 {
t.Errorf("非文件檔應計數 2jpg+zip),實得 %d", p.SkippedOther)
}
}
// manifest 不該因為「有讀不了的檔」而被污染——它們沒進管線,就不該有帳本條目。
func TestSkippedFilesStayOutOfManifest(t *testing.T) {
root := t.TempDir()
writeFile(t, root, "好的.md", "x", baseTime)
writeFile(t, root, "壞的.doc", "x", baseTime)
m := newTestManifest()
mustScan(t, root, m)
if _, ok := m.Entries["壞的.doc"]; ok {
t.Error("讀不了的檔不該進 manifest(會被誤當成已收錄)")
}
if _, ok := m.Entries["好的.md"]; !ok {
t.Error("支援的檔應照常進 manifest")
}
}
// 🔴 schema 保護:collector-trigger.v1.schema.json 頂層是 additionalProperties:false
// 多帶一個欄位上線就會被雲端擋掉。Skipped 是**給本機使用者看的**,不准漏到 wire 上。
// BuildSendablePayload 是 `sendable := *p` 淺拷貝 ⇒ 有 json tag 就會一起送出去。)
func TestSkippedNeverGoesOnTheWire(t *testing.T) {
root := t.TempDir()
writeFile(t, root, "壞的.doc", "x", baseTime)
writeFile(t, root, "照片.jpg", "x", baseTime)
p := mustScan(t, root, newTestManifest())
if len(p.Skipped) == 0 {
t.Fatal("前提不成立:這輪應該要有被略過的檔")
}
blob, err := json.Marshal(p)
if err != nil {
t.Fatal(err)
}
for _, forbidden := range []string{"skipped", "壞的.doc"} {
if strings.Contains(string(blob), forbidden) {
t.Errorf("送雲端的 payload 不該出現 %q%s", forbidden, blob)
}
}
}
// 隱藏檔/被排除的目錄不該被點名——那些是系統與產品自己的東西,
// 使用者從來沒把它們當成「我丟進去的檔案」。報了只會變噪音。
func TestSkippedIgnoresHiddenAndExcluded(t *testing.T) {
root := t.TempDir()
writeFile(t, root, ".DS_Store", "x", baseTime)
writeFile(t, root, ".hidden.doc", "x", baseTime)
writeFile(t, root, ".obsidian/workspace.key", "x", baseTime)
writeFile(t, root, "system-dev/舊稿.doc", "x", baseTime)
writeFile(t, root, "真的.doc", "x", baseTime)
m := newTestManifest()
p, err := Scan(root, m, ScanOptions{SkipDirNames: map[string]bool{"system-dev": true}})
if err != nil {
t.Fatal(err)
}
if len(p.Skipped) != 1 || p.Skipped[0].Path != "真的.doc" {
t.Fatalf("只有使用者自己的檔該被點名,實得 %+v", p.Skipped)
}
if p.SkippedOther != 0 {
t.Errorf("隱藏檔不該進非文件計數,實得 %d", p.SkippedOther)
}
}
// 沒有任何讀不了的檔時,兩個欄位都該是零值 ⇒ UI 才不會憑空冒出一張卡。
func TestNoSkippedWhenEverythingSupported(t *testing.T) {
root := t.TempDir()
writeFile(t, root, "a.md", "x", baseTime)
writeFile(t, root, "b.txt", "y", baseTime)
p := mustScan(t, root, newTestManifest())
if len(p.Skipped) != 0 || p.SkippedOther != 0 {
t.Errorf("全部都讀得了時不該有略過紀錄:%+v / %d", p.Skipped, p.SkippedOther)
}
}