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(不再夾帶第二份完整程式)
238 lines
8.0 KiB
Go
238 lines
8.0 KiB
Go
package collector
|
||
|
||
import (
|
||
"io"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
const (
|
||
testAccount = "acct-test"
|
||
testBucket = "bkt-test"
|
||
testToken = "tok-test" // 假 token,只給 httptest mock 驗 header 用
|
||
)
|
||
|
||
// mockR2 用 httptest 模擬 Cloudflare R2 REST API 的 objects 端點。
|
||
// 對齊 2026-07-19 live 實測行為:HEAD 回 405(真 API 不支援)、存在檢查走 GET。
|
||
type mockR2 struct {
|
||
mu sync.Mutex
|
||
objects map[string][]byte
|
||
existsCount int // GET(存在檢查)次數
|
||
putCount int
|
||
failPut bool // true=PUT 一律回 500(模擬上傳失敗)
|
||
}
|
||
|
||
func newMockR2(t *testing.T) (*httptest.Server, *mockR2, *R2Client) {
|
||
t.Helper()
|
||
m := &mockR2{objects: map[string][]byte{}}
|
||
prefix := "/client/v4/accounts/" + testAccount + "/r2/buckets/" + testBucket + "/objects/"
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if r.Header.Get("Authorization") != "Bearer "+testToken {
|
||
w.WriteHeader(http.StatusForbidden)
|
||
return
|
||
}
|
||
if !strings.HasPrefix(r.URL.Path, prefix) {
|
||
t.Errorf("非預期路徑: %s", r.URL.Path)
|
||
w.WriteHeader(http.StatusNotFound)
|
||
return
|
||
}
|
||
key := strings.TrimPrefix(r.URL.Path, prefix)
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
switch r.Method {
|
||
case http.MethodHead: // 真 API 行為:objects 端點不支援 HEAD
|
||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||
case http.MethodGet:
|
||
m.existsCount++
|
||
body, ok := m.objects[key]
|
||
if !ok {
|
||
w.WriteHeader(http.StatusNotFound)
|
||
io.WriteString(w, `{"success":false,"errors":[{"code":10007,"message":"object not found"}]}`)
|
||
return
|
||
}
|
||
if r.Header.Get("Range") != "" && len(body) > 0 {
|
||
w.WriteHeader(http.StatusPartialContent)
|
||
w.Write(body[:1])
|
||
return
|
||
}
|
||
w.Write(body)
|
||
case http.MethodPut:
|
||
m.putCount++
|
||
if m.failPut {
|
||
w.WriteHeader(http.StatusInternalServerError)
|
||
io.WriteString(w, `{"success":false,"errors":[{"code":10000,"message":"mock 上傳失敗"}]}`)
|
||
return
|
||
}
|
||
body, err := io.ReadAll(r.Body)
|
||
if err != nil {
|
||
w.WriteHeader(http.StatusBadRequest)
|
||
return
|
||
}
|
||
m.objects[key] = body
|
||
io.WriteString(w, `{"success":true}`)
|
||
default:
|
||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||
}
|
||
}))
|
||
t.Cleanup(srv.Close)
|
||
client := NewR2Client(R2Config{
|
||
AccountID: testAccount, APIToken: testToken, Bucket: testBucket,
|
||
BaseURL: srv.URL + "/client/v4",
|
||
})
|
||
return srv, m, client
|
||
}
|
||
|
||
// 情境 1:新檔上傳——PUT 到 raw/<sha256hex>、body 與檔案內容一致、帶 Bearer token;
|
||
// 上傳成功也「不」標 ingested(ingested_hash 由 task 4 整鏈成功後經 MarkIngested 回寫)。
|
||
func TestUploadNew(t *testing.T) {
|
||
_, mock, client := newMockR2(t)
|
||
root := t.TempDir()
|
||
content := "上傳測試內容 v1\n"
|
||
writeFile(t, root, "a.md", content, baseTime)
|
||
m := newTestManifest()
|
||
p := mustScan(t, root, m)
|
||
|
||
results := UploadChanged(root, p.Events, client)
|
||
|
||
if len(results) != 1 || results[0].Status != "uploaded" {
|
||
t.Fatalf("要 1 筆 uploaded,得到: %+v", results)
|
||
}
|
||
wantKey := "raw/" + strings.TrimPrefix(hashOf(content), "sha256:")
|
||
if results[0].R2Key != wantKey {
|
||
t.Fatalf("r2_key 不對: %s", results[0].R2Key)
|
||
}
|
||
if got, ok := mock.objects[wantKey]; !ok || string(got) != content {
|
||
t.Fatalf("R2 端物件內容不符: ok=%v got=%q", ok, string(got))
|
||
}
|
||
if mock.putCount != 1 || mock.existsCount != 1 {
|
||
t.Fatalf("要 1 存在檢查 + 1 PUT,得到 exists=%d put=%d", mock.existsCount, mock.putCount)
|
||
}
|
||
if m.Entries["a.md"].IngestedHash != "" {
|
||
t.Fatal("上傳成功 ≠ ingest 完成,掃描/上傳階段不得寫 ingested_hash")
|
||
}
|
||
}
|
||
|
||
// 情境 2:同 hash 重傳=no-op——存在檢查命中就不 PUT(content-addressed 冪等,design §4)。
|
||
func TestUploadExistingNoOp(t *testing.T) {
|
||
_, mock, client := newMockR2(t)
|
||
root := t.TempDir()
|
||
content := "同一份內容\n"
|
||
writeFile(t, root, "b.md", content, baseTime)
|
||
key := "raw/" + strings.TrimPrefix(hashOf(content), "sha256:")
|
||
mock.objects[key] = []byte(content) // 模擬先前已上傳過同 hash(可能來自別的路徑/別台機器)
|
||
|
||
m := newTestManifest()
|
||
p := mustScan(t, root, m)
|
||
results := UploadChanged(root, p.Events, client)
|
||
|
||
if len(results) != 1 || results[0].Status != "skipped_exists" {
|
||
t.Fatalf("要 skipped_exists,得到: %+v", results)
|
||
}
|
||
if mock.putCount != 0 {
|
||
t.Fatalf("存在檢查命中後不得 PUT,putCount=%d", mock.putCount)
|
||
}
|
||
if mock.existsCount != 1 {
|
||
t.Fatalf("要恰好 1 次存在檢查,得到 %d", mock.existsCount)
|
||
}
|
||
}
|
||
|
||
// 情境 3:上傳失敗(PUT 500)——結果標 failed 帶錯誤、絕不標 ingested;
|
||
// manifest 的 content_hash 照掃描更新(ingested_hash 仍空=下輪自然重試,design §2)。
|
||
func TestUploadFailedNotIngested(t *testing.T) {
|
||
_, mock, client := newMockR2(t)
|
||
mock.failPut = true
|
||
root := t.TempDir()
|
||
writeFile(t, root, "c.md", "會失敗的內容\n", baseTime)
|
||
m := newTestManifest()
|
||
p := mustScan(t, root, m)
|
||
|
||
results := UploadChanged(root, p.Events, client)
|
||
|
||
if len(results) != 1 || results[0].Status != "failed" || results[0].Error == "" {
|
||
t.Fatalf("要 failed+錯誤訊息,得到: %+v", results)
|
||
}
|
||
e := m.Entries["c.md"]
|
||
if e == nil || e.IngestedHash != "" || e.IngestedAt != 0 {
|
||
t.Fatalf("上傳失敗不得標 ingested: %+v", e)
|
||
}
|
||
if e.ContentHash != hashOf("會失敗的內容\n") {
|
||
t.Fatalf("content_hash 應照掃描更新(重試靠 ingested_hash 空): %+v", e)
|
||
}
|
||
// 失敗後同檔重掃=事件重發(重試語意)
|
||
p2 := mustScan(t, root, m)
|
||
if len(p2.Events) != 1 || p2.Events[0].Type != "added" {
|
||
t.Fatalf("失敗後下輪應重發 added: %+v", p2.Events)
|
||
}
|
||
}
|
||
|
||
// 附加:renamed/removed 事件不上傳(內容未變/已留底,design §3+§4)。
|
||
func TestUploadSkipsNonContentEvents(t *testing.T) {
|
||
_, mock, client := newMockR2(t)
|
||
root := t.TempDir()
|
||
results := UploadChanged(root, []Event{
|
||
{Type: "renamed", Path: "x.md", OldPath: "y.md", SourceHash: hashOf("x")},
|
||
{Type: "removed", Path: "z.md", SourceHash: hashOf("z")},
|
||
}, client)
|
||
if len(results) != 0 {
|
||
t.Fatalf("renamed/removed 不該有上傳結果: %+v", results)
|
||
}
|
||
if mock.existsCount != 0 || mock.putCount != 0 {
|
||
t.Fatalf("不該碰網路: head=%d put=%d", mock.existsCount, mock.putCount)
|
||
}
|
||
}
|
||
|
||
// 附加:content-addressed 完整性——檔案在掃描後被改動(hash 不符 key)=failed 不上傳,
|
||
// 不能把新內容塞進舊 hash 的 key。
|
||
func TestUploadHashMismatch(t *testing.T) {
|
||
_, mock, client := newMockR2(t)
|
||
root := t.TempDir()
|
||
writeFile(t, root, "d.md", "掃描時內容\n", baseTime)
|
||
m := newTestManifest()
|
||
p := mustScan(t, root, m)
|
||
|
||
// 掃描後、上傳前檔案被改動
|
||
writeFile(t, root, "d.md", "上傳前偷偷改了\n", baseTime.Add(time.Second))
|
||
results := UploadChanged(root, p.Events, client)
|
||
|
||
if len(results) != 1 || results[0].Status != "failed" {
|
||
t.Fatalf("hash 不符應 failed: %+v", results)
|
||
}
|
||
if mock.putCount != 0 {
|
||
t.Fatalf("hash 不符不得 PUT: %d", mock.putCount)
|
||
}
|
||
}
|
||
|
||
// 附加:設定缺環境變數=清楚報缺哪幾個。
|
||
func TestLoadR2ConfigMissing(t *testing.T) {
|
||
t.Setenv("CF_ACCOUNT_ID", "")
|
||
t.Setenv("CF_API_TOKEN", "")
|
||
t.Setenv("R2_BUCKET", "b")
|
||
_, err := LoadR2ConfigFromEnv()
|
||
if err == nil {
|
||
t.Fatal("缺 env 應報錯")
|
||
}
|
||
msg := err.Error()
|
||
if !strings.Contains(msg, "CF_ACCOUNT_ID") || !strings.Contains(msg, "CF_API_TOKEN") || strings.Contains(msg, "R2_BUCKET,") {
|
||
t.Fatalf("錯誤訊息應列出缺的變數: %s", msg)
|
||
}
|
||
}
|
||
|
||
// 附加:MarkIngested 回寫鉤子(task 4 用)——存在的路徑回寫成功、消失的路徑回 false。
|
||
func TestMarkIngested(t *testing.T) {
|
||
m := newTestManifest()
|
||
m.Entries["a.md"] = &ManifestEntry{ContentHash: hashOf("a"), Size: 1, Mtime: 2}
|
||
if !m.MarkIngested("a.md", hashOf("a"), 99) {
|
||
t.Fatal("存在的路徑應回寫成功")
|
||
}
|
||
if m.Entries["a.md"].IngestedHash != hashOf("a") || m.Entries["a.md"].IngestedAt != 99 {
|
||
t.Fatalf("回寫值不對: %+v", m.Entries["a.md"])
|
||
}
|
||
if m.MarkIngested("gone.md", hashOf("g"), 1) {
|
||
t.Fatal("不存在的路徑應回 false")
|
||
}
|
||
}
|