Files
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

252 lines
8.9 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"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync"
"testing"
"time"
)
// mockTrigger 用 httptest 模擬 arcrun named-webhook 觸發端點。
type mockTrigger struct {
mu sync.Mutex
status int // 回應狀態碼(預設 200
payloads []*TriggerPayload
}
func newMockTrigger(t *testing.T) (*httptest.Server, *mockTrigger) {
t.Helper()
m := &mockTrigger{status: http.StatusOK}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
t.Errorf("Content-Type 應為 application/json,得到 %q", ct)
}
body, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
var p TriggerPayload
if err := json.Unmarshal(body, &p); err != nil {
t.Errorf("payload 不是合法 JSON: %v", err)
}
m.mu.Lock()
m.payloads = append(m.payloads, &p)
st := m.status
m.mu.Unlock()
w.WriteHeader(st)
if st >= 500 {
io.WriteString(w, `{"error":"mock 觸發失敗"}`)
} else {
io.WriteString(w, `{"success":true,"execution_id":"mock-exec"}`)
}
}))
t.Cleanup(srv.Close)
return srv, m
}
// syncRound 模擬 sync 的一輪:scan → upload(真 mock R2)→ 過濾 → POST → 2xx 才回寫。
// 與 main.run 的 sync 分支同邏輯(單測不跑 CLI 殼,直接組核心函式)。
func syncRound(t *testing.T, root string, m *Manifest, r2 *R2Client, url string) (*TriggerPayload, []UploadResult, *TriggerResult) {
t.Helper()
p := mustScan(t, root, m)
uploads := UploadChanged(root, p.Events, r2)
sendable, dropped := BuildSendablePayload(p, uploads)
res := &TriggerResult{DroppedPaths: dropped}
if len(sendable.Events) == 0 && len(sendable.Warnings) == 0 {
res.Status = "skipped_no_changes"
return sendable, uploads, res
}
status, err := SendTrigger(url, sendable, nil)
res.HTTPStatus = status
if err != nil {
res.Status = "failed"
res.Error = err.Error()
return sendable, uploads, res
}
res.Status = "sent"
res.MarkedCount = MarkIngestedEvents(m, sendable.Events, dropped, time.Now().Unix())
return sendable, uploads, res
}
// 情境 1:成功回寫——上傳全成、觸發 2xx → added 事件的檔回寫 ingested_hash
// 下一輪掃描歸零(不重發);伺服器收到的 payload 帶 schema_version/folder_id/r2_key。
func TestSyncSuccessMarksIngested(t *testing.T) {
_, _, r2 := newMockR2(t)
srv, trig := newMockTrigger(t)
root := t.TempDir()
content := "sync 測試內容\n"
writeFile(t, root, "a.md", content, baseTime)
m := newTestManifest()
_, uploads, res := syncRound(t, root, m, r2, srv.URL)
if len(uploads) != 1 || uploads[0].Status != "uploaded" {
t.Fatalf("要 1 筆 uploaded: %+v", uploads)
}
if res.Status != "sent" || res.MarkedCount != 1 || len(res.DroppedPaths) != 0 {
t.Fatalf("要 sentmarked 1: %+v", res)
}
e := m.Entries["a.md"]
if e.IngestedHash != hashOf(content) || e.IngestedAt == 0 {
t.Fatalf("觸發 2xx 後應回寫 ingested_hash: %+v", e)
}
// 伺服器收到的 payload 符合約定
if len(trig.payloads) != 1 {
t.Fatalf("要恰好 1 發觸發: %d", len(trig.payloads))
}
got := trig.payloads[0]
if got.SchemaVersion != 1 || got.FolderID == "" {
t.Fatalf("payload 頭欄位不對: %+v", got)
}
if len(got.Events) != 1 || got.Events[0].Type != "added" || got.Events[0].R2Key == "" {
t.Fatalf("要 1 筆帶 r2_key 的 added: %+v", got.Events)
}
// 下一輪:無變更=零事件、不再觸發
_, _, res2 := syncRound(t, root, m, r2, srv.URL)
if res2.Status != "skipped_no_changes" || len(trig.payloads) != 1 {
t.Fatalf("無變更輪不該再觸發: %+v(觸發數 %d)", res2, len(trig.payloads))
}
}
// 情境 2:觸發失敗(500)——不回寫 ingested_hash,下一輪同檔重發=自然重試;
// 修好後(200)重試成功才回寫。
func TestSyncFailureNoMarkThenRetry(t *testing.T) {
_, _, r2 := newMockR2(t)
srv, trig := newMockTrigger(t)
trig.status = http.StatusInternalServerError
root := t.TempDir()
content := "會先失敗的內容\n"
writeFile(t, root, "b.md", content, baseTime)
m := newTestManifest()
_, _, res := syncRound(t, root, m, r2, srv.URL)
if res.Status != "failed" || res.Error == "" || res.HTTPStatus != 500 {
t.Fatalf("要 failed+錯誤訊息: %+v", res)
}
if m.Entries["b.md"].IngestedHash != "" {
t.Fatal("觸發失敗不得回寫 ingested_hash")
}
// 修好 → 下一輪重發(R2 端 skipped_exists no-op)→ 2xx → 回寫
trig.status = http.StatusOK
sendable, uploads, res2 := syncRound(t, root, m, r2, srv.URL)
if res2.Status != "sent" || res2.MarkedCount != 1 {
t.Fatalf("重試輪應 sentmarked 1: %+v", res2)
}
if len(uploads) != 1 || uploads[0].Status != "skipped_exists" {
t.Fatalf("重試輪 R2 應 no-op: %+v", uploads)
}
if len(sendable.Events) != 1 || sendable.Events[0].Type != "added" {
t.Fatalf("重試輪應重發 added: %+v", sendable.Events)
}
if m.Entries["b.md"].IngestedHash != hashOf(content) {
t.Fatal("重試成功後應回寫 ingested_hash")
}
}
// 情境 3:防呆警告輪(mass_delete_guard)——removed 事件被壓下,但 payload 連同
// warnings 照送(消費端看得到警告、不執行下架);沒有任何回寫。
func TestSyncGuardRoundStillSendsWarnings(t *testing.T) {
_, _, r2 := newMockR2(t)
srv, trig := newMockTrigger(t)
root := t.TempDir()
m := newTestManifest()
// 先建 3 檔並完成一輪成功 sync(全部標 ingested
for _, n := range []string{"a.md", "b.md", "c.md"} {
writeFile(t, root, n, n+" content\n", baseTime)
}
if _, _, res := syncRound(t, root, m, r2, srv.URL); res.Status != "sent" || res.MarkedCount != 3 {
t.Fatalf("前置輪應全成: %+v", res)
}
// 刪 2/367% > 40% 門檻)→ 防呆
os.Remove(root + "/a.md")
os.Remove(root + "/b.md")
sendable, _, res := syncRound(t, root, m, r2, srv.URL)
if res.Status != "sent" {
t.Fatalf("警告輪應照送: %+v", res)
}
if len(sendable.Events) != 0 {
t.Fatalf("防呆輪不得夾帶任何事件(removed 已壓下): %+v", sendable.Events)
}
if len(sendable.Warnings) != 1 || sendable.Warnings[0].Code != "mass_delete_guard" {
t.Fatalf("要 mass_delete_guard 警告: %+v", sendable.Warnings)
}
got := trig.payloads[len(trig.payloads)-1]
if len(got.Warnings) != 1 || got.Warnings[0].Code != "mass_delete_guard" || len(got.Events) != 0 {
t.Fatalf("伺服器端收到的警告輪不對: %+v", got)
}
if res.MarkedCount != 0 {
t.Fatalf("警告輪無內容事件,不該回寫: %+v", res)
}
}
// 情境 4(純函式):上傳失敗的 added/modified 不隨 payload 送出;同路徑的 renamed
// 也不得回寫(否則內容從未上 R2 卻被標 ingested)。
func TestBuildSendableDropsFailedUploads(t *testing.T) {
h1, h2 := hashOf("one"), hashOf("two")
p := &TriggerPayload{
SchemaVersion: 1, FolderID: "f",
Events: []Event{
{Type: "added", Path: "ok.md", SourceHash: h1, R2Key: r2KeyOf(h1)},
{Type: "added", Path: "bad.md", SourceHash: h2, R2Key: r2KeyOf(h2)},
{Type: "renamed", Path: "bad.md", OldPath: "old-bad.md", SourceHash: h2},
{Type: "removed", Path: "gone.md", SourceHash: hashOf("g")},
},
}
uploads := []UploadResult{
{Path: "ok.md", Status: "uploaded"},
{Path: "bad.md", Status: "failed", Error: "mock"},
}
sendable, dropped := BuildSendablePayload(p, uploads)
if len(dropped) != 1 || dropped[0] != "bad.md" {
t.Fatalf("要擋下 bad.md: %v", dropped)
}
types := []string{}
for _, ev := range sendable.Events {
types = append(types, ev.Type+":"+ev.Path)
}
want := "added:ok.md renamed:bad.md removed:gone.md"
if strings.Join(types, " ") != want {
t.Fatalf("送出清單不對:%v(要 %s", types, want)
}
// 回寫:ok.md 回寫;bad.md 的 renamed 因同路徑被擋下也不回寫
m := newTestManifest()
m.Entries["ok.md"] = &ManifestEntry{ContentHash: h1}
m.Entries["bad.md"] = &ManifestEntry{ContentHash: h2}
n := MarkIngestedEvents(m, sendable.Events, dropped, 42)
if n != 1 || m.Entries["ok.md"].IngestedHash != h1 {
t.Fatalf("只該回寫 ok.md: n=%d %+v", n, m.Entries["ok.md"])
}
if m.Entries["bad.md"].IngestedHash != "" {
t.Fatal("上傳失敗檔的 renamed 不得回寫 ingested_hash")
}
}
// 附加:ARCRUN_TRIGGER_URL 缺漏/格式錯,清楚報錯。
func TestLoadTriggerURL(t *testing.T) {
t.Setenv("ARCRUN_TRIGGER_URL", "")
if _, err := LoadTriggerURLFromEnv(); err == nil || !strings.Contains(err.Error(), "ARCRUN_TRIGGER_URL") {
t.Fatalf("缺 env 應報含變數名的錯: %v", err)
}
t.Setenv("ARCRUN_TRIGGER_URL", "not-a-url")
if _, err := LoadTriggerURLFromEnv(); err == nil {
t.Fatal("非完整 URL 應報錯")
}
t.Setenv("ARCRUN_TRIGGER_URL", "https://example.com/webhooks/named/demo/rag_ingest/trigger")
u, err := LoadTriggerURLFromEnv()
if err != nil || u == "" {
t.Fatalf("合法 URL 應通過: %v", err)
}
}