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(不再夾帶第二份完整程式)
252 lines
8.9 KiB
Go
252 lines
8.9 KiB
Go
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("要 sent+marked 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("重試輪應 sent+marked 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/3(67% > 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)
|
||
}
|
||
}
|