Files
arcrun-collector/scan.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

370 lines
13 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.
// scan.go — 掃描迴圈與差異分類(SDD ingest-hash-trigger design §3)。
// 事件順序:先把本輪 removed×added 以 content_hash 配對成 renamed(只更新路徑映射),
// 再分類其餘 added/modified/removedremoved 數 > manifest 條目 × 門檻(預設 40%)
// → removed 全部不執行、改發警告(R6)。本階段不接網路,事件輸出到 stdout。
package collector
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// 收檔白名單。**注意這只是「收不收」,能不能讀由 convert.go 的 extractors 決定**——
// 兩者要一起看(2026-07-27 t73`.pdf` 早就在這裡,但 ingest 端擋著=檔案上了 R2 卻進不了
// 知識庫,使用者看到的是「丟檔進去沒反應」)。
//
// .csv/.xlsx 於 2026-07-27 加入——leo:「要思考 Excel 和 csv 的問題,**因為企業用很多**」。
var allowedExt = map[string]bool{
".md": true,
".markdown": true,
".txt": true,
".docx": true,
".pptx": true,
".pdf": true,
".csv": true,
".xlsx": true,
}
// docLikeExt=「使用者明顯把它當文件、但我們還讀不了」的副檔名。
//
// 🔴 為什麼要有這張表(J-1/S6 考題 G-6.22026-08-06):
// 下面那道 `!allowedExt[...]` 的閘**直接 return nil**——不進事件、不進 manifest、
// 不進 status、不進畫面。使用者把一份 `.doc` 丟進資料夾,**整個系統從頭到尾一個字都不說**,
// 他只會覺得「這東西壞了」。G-6.2 的判準是:要嘛查得到,**要嘛當場被告知不支援**;
// 「安靜地略過」不是可接受的第三種結果。
//
// 為什麼是白名單、而不是「非 allowedExt 一律點名」:後者在 Obsidian 附件庫(幾百張 .png
// 或程式碼資料夾裡會炸出幾百行「處理不了」=噪音,使用者反而學會忽略整塊訊息。
// ⇒ **像文件的逐檔點名,其餘只報一個總數**(見 Scan 的 SkippedOther)。兩種都不沉默,
// 但只有前者值得佔用他的注意力。
//
// 加新格式的順序:先列在這裡(使用者立刻看得到「還不支援」),
// 等 convert.go 真的接上抽取器,再把它從這裡搬去 allowedExt。
var docLikeExt = map[string]bool{
// 舊版 OfficeOLE2 二進位,與 .docx/.xlsx/.pptx 是完全不同的格式)
".doc": true, ".xls": true, ".ppt": true,
// Apple iWork
".pages": true, ".numbers": true, ".key": true,
// OpenDocumentLibreOffice
".odt": true, ".ods": true, ".odp": true,
// 其他常見文件容器
".rtf": true, ".epub": true, ".wpd": true, ".msg": true, ".eml": true,
}
// SkippedFile=這一輪被略過、且值得對使用者逐檔點名的檔案。
//
// ⚠️ 刻意**不寫進 manifest**:它每輪由檔案系統重算,永遠反映現況。
// (對照 t195 的坑:凡是存進 ManifestEntry 的跨輪欄位都得記得在 carry 段補一行,
// 漏了就靜默歸零。這裡不建立那份債。)
type SkippedFile struct {
Path string `json:"path"`
Ext string `json:"ext"`
}
// ---- 輸出 payload(對應 schemas/collector-trigger.v1.schema.json----
type Event struct {
Type string `json:"type"`
Path string `json:"path"`
OldPath string `json:"old_path,omitempty"`
SourceHash string `json:"source_hash"`
Size *int64 `json:"size,omitempty"`
R2Key string `json:"r2_key,omitempty"`
}
type Warning struct {
Code string `json:"code"`
Message string `json:"message"`
RemovedCount int `json:"removed_count,omitempty"`
ManifestCount int `json:"manifest_count,omitempty"`
ThresholdRatio float64 `json:"threshold_ratio,omitempty"`
}
type TriggerPayload struct {
SchemaVersion int `json:"schema_version"`
FolderID string `json:"folder_id"`
Root string `json:"root,omitempty"`
GeneratedAt int64 `json:"generated_at,omitempty"`
Events []Event `json:"events"`
Warnings []Warning `json:"warnings,omitempty"`
// 🔴 兩個 `json:"-"`G-6.22026-08-06):被略過的檔案是**給本機使用者看的**,
// 不是給雲端 ingest 的料。collector-trigger.v1.schema.json 頂層寫死
// `additionalProperties: false`,多帶一個欄位上線就會被 schema 擋掉
//BuildSendablePayload 是 `sendable := *p` 淺拷貝,有 tag 就會一起送出去)。
// ⇒ 留在記憶體裡,由 direct.go 收進 status.json,給 App 首頁用。
Skipped []SkippedFile `json:"-"` // 像文件、但還讀不了的(逐檔點名)
SkippedOther int `json:"-"` // 其餘非文件檔(圖片/影音/程式碼…)只計數
}
type ScanOptions struct {
// MaxRemovedRatio:單輪 removed 數 > manifest 條目數 × 本值 → 觸發大量刪除防呆(R6)。
MaxRemovedRatio float64
// SkipPaths:絕對路徑黑名單(如 manifest 檔自己住在 root 底下時)。
SkipPaths map[string]bool
// SkipDirNames:目錄名黑名單(任一層命中整棵跳過)。daemon-beta task 2
// template 代裝後 `system-dev/`wiki 產物區)不得被當成原稿掃進 ingest。
SkipDirNames map[string]bool
}
const DefaultMaxRemovedRatio = 0.4
type fileState struct {
hash string // sha256:<hex>
size int64
mtime int64
}
func hashFile(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return "sha256:" + hex.EncodeToString(h.Sum(nil)), nil
}
func r2KeyOf(sourceHash string) string {
return "raw/" + strings.TrimPrefix(sourceHash, "sha256:")
}
// Scan 走訪 root、對照並更新 manifest、產出一輪事件。
// manifest 更新原則:content_hash/size/mtime 反映現況;ingested_hash/ingested_at
// 只搬運(renamed)與保留(modified),本函式永不設值——那是上傳成功後的事。
func Scan(root string, m *Manifest, opts ScanOptions) (*TriggerPayload, error) {
if opts.MaxRemovedRatio <= 0 {
opts.MaxRemovedRatio = DefaultMaxRemovedRatio
}
orig := m.Entries
manifestCountBefore := len(orig)
// 1) 走訪檔案系統,建立現況(mtime+size fast-path:沒變→沿用 manifest hash,變了才算 sha256)。
current := map[string]fileState{}
var skipped []SkippedFile
skippedOther := 0
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, werr error) error {
if werr != nil {
return werr
}
name := d.Name()
if d.IsDir() {
if p != root && strings.HasPrefix(name, ".") {
return filepath.SkipDir // 隱藏目錄(.git、.obsidian…)整棵跳過
}
if p != root && opts.SkipDirNames[name] {
return filepath.SkipDir // 名單目錄(system-dev…)整棵跳過
}
return nil
}
if strings.HasPrefix(name, ".") {
return nil
}
if abs, aerr := filepath.Abs(p); aerr == nil && opts.SkipPaths[abs] {
return nil
}
ext := strings.ToLower(filepath.Ext(name))
if !allowedExt[ext] {
// G-6.2**這裡以前是條死巷**——`return nil` 之後這個檔就從世界上消失了。
// 現在留個名,讓 direct.go 有東西可以寫進 status.json、App 有東西可以顯示。
if docLikeExt[ext] {
if rel, rerr := filepath.Rel(root, p); rerr == nil {
skipped = append(skipped, SkippedFile{Path: filepath.ToSlash(rel), Ext: ext})
}
} else {
skippedOther++
}
return nil
}
info, ierr := d.Info()
if ierr != nil {
return ierr
}
rel, rerr := filepath.Rel(root, p)
if rerr != nil {
return rerr
}
rel = filepath.ToSlash(rel)
st := fileState{size: info.Size(), mtime: info.ModTime().Unix()}
if e, ok := orig[rel]; ok && e.ContentHash != "" && e.Mtime == st.mtime && e.Size == st.size {
st.hash = e.ContentHash // fast-pathmtime+size 沒變,跳過重算
} else {
h, herr := hashFile(p)
if herr != nil {
return herr
}
st.hash = h
}
current[rel] = st
return nil
})
if err != nil {
return nil, err
}
// 2) 初分:added 候選(現況有、manifest 無)與 removed 候選(manifest 有、現況無)。
var addedPaths, removedPaths []string
for p := range current {
if _, ok := orig[p]; !ok {
addedPaths = append(addedPaths, p)
}
}
for p := range orig {
if _, ok := current[p]; !ok {
removedPaths = append(removedPaths, p)
}
}
sort.Strings(addedPaths)
sort.Strings(removedPaths)
// 3) 先配對 renameddesign §3 順序 1):removed×added 以 content_hash 配對,
// 配上=只更新路徑映射,不 retire、不重萃、不重傳。同 hash 多候選→排序後貪婪配對(確定性)。
removedByHash := map[string][]string{}
for _, p := range removedPaths {
h := orig[p].ContentHash
removedByHash[h] = append(removedByHash[h], p)
}
renamedOldOf := map[string]string{} // newPath -> oldPath
pairedOld := map[string]bool{}
var events []Event
for _, np := range addedPaths {
h := current[np].hash
cands := removedByHash[h]
if len(cands) == 0 {
continue
}
op := cands[0]
removedByHash[h] = cands[1:]
pairedOld[op] = true
renamedOldOf[np] = op
events = append(events, Event{Type: "renamed", Path: np, OldPath: op, SourceHash: h})
}
// 4) added:真新檔+「曾偵測但從未成功 ingest」的檔(重試語意,design §2)。
sortedCurrent := make([]string, 0, len(current))
for p := range current {
sortedCurrent = append(sortedCurrent, p)
}
sort.Strings(sortedCurrent)
addedEvent := func(p string) Event {
st := current[p]
size := st.size
return Event{Type: "added", Path: p, SourceHash: st.hash, Size: &size, R2Key: r2KeyOf(st.hash)}
}
for _, p := range sortedCurrent {
if op, isRenamed := renamedOldOf[p]; isRenamed {
if orig[op].IngestedHash == "" { // 改名的檔其實從未 ingest 成功 → 補一發 added
events = append(events, addedEvent(p))
}
continue
}
if _, existed := orig[p]; !existed {
events = append(events, addedEvent(p)) // 真新檔
} else if orig[p].IngestedHash == "" {
events = append(events, addedEvent(p)) // 上輪偵測過但 ingest 未成功 → 重試
}
}
// 5) modifiedmanifest 有、現況有、content_hash != ingested_hashdesign §3 順序 3)。
for _, p := range sortedCurrent {
e, existed := orig[p]
if !existed {
continue
}
if _, isRenamed := renamedOldOf[p]; isRenamed {
continue
}
if e.IngestedHash != "" && current[p].hash != e.IngestedHash {
st := current[p]
size := st.size
events = append(events, Event{Type: "modified", Path: p, SourceHash: st.hash, Size: &size, R2Key: r2KeyOf(st.hash)})
}
}
// 6) removed(扣掉已配對走的)+大量刪除防呆(R6)。
var finalRemoved []string
for _, p := range removedPaths {
if !pairedOld[p] {
finalRemoved = append(finalRemoved, p)
}
}
var warnings []Warning
guardTripped := manifestCountBefore > 0 &&
float64(len(finalRemoved)) > opts.MaxRemovedRatio*float64(manifestCountBefore)
if guardTripped {
warnings = append(warnings, Warning{
Code: "mass_delete_guard",
Message: fmt.Sprintf(
"本輪偵測到 %d/%d 個檔案消失(超過 %.0f%% 門檻)——可能是資料夾未掛載或同步半途。本輪全部「不」下架,請確認資料夾完好後再放行。",
len(finalRemoved), manifestCountBefore, opts.MaxRemovedRatio*100),
RemovedCount: len(finalRemoved),
ManifestCount: manifestCountBefore,
ThresholdRatio: opts.MaxRemovedRatio,
})
} else {
for _, p := range finalRemoved {
events = append(events, Event{Type: "removed", Path: p, SourceHash: orig[p].ContentHash})
}
}
// 7) 更新 manifestrebuild):現況檔全數收錄;ingested_* 由舊 entry(或 renamed 的舊路徑)搬運。
// 防呆觸發時 removed 條目保留(下輪重評、警告會再響,直到人確認或檔案回來)。
newEntries := make(map[string]*ManifestEntry, len(current))
for p, st := range current {
ne := &ManifestEntry{ContentHash: st.hash, Size: st.size, Mtime: st.mtime}
var carry *ManifestEntry
if op, isRenamed := renamedOldOf[p]; isRenamed {
carry = orig[op]
} else if e, ok := orig[p]; ok {
carry = e
}
if carry != nil {
ne.IngestedHash = carry.IngestedHash
ne.IngestedAt = carry.IngestedAt
// 🔴 t195:掃描每輪都**重建** entry,原本只 carry 上面兩欄 ⇒ 其餘欄位靜默歸零。
// 實撞:失敗退避(fail_count/next_retry)寫進去了,下一輪掃描卻被抹掉
// ⇒ 退避永遠停在「第 1 次失敗」,等同沒有退避(1387 輪的病根之一)。
// ExtractedBy(t73 記的「誰萃的」)原本也一樣悄悄丟失。
// ⚠️ 之後在 ManifestEntry 新增任何「跨輪要記住」的欄位,都必須加在這裡。
ne.ExtractedBy = carry.ExtractedBy
ne.FailCount = carry.FailCount
ne.LastFailAt = carry.LastFailAt
ne.NextRetry = carry.NextRetry
}
newEntries[p] = ne
}
if guardTripped {
for _, p := range finalRemoved {
newEntries[p] = orig[p]
}
}
m.Entries = newEntries
m.Root = root
if events == nil {
events = []Event{}
}
sort.Slice(skipped, func(i, j int) bool { return skipped[i].Path < skipped[j].Path })
return &TriggerPayload{
SchemaVersion: 1,
FolderID: m.FolderID,
Root: root,
GeneratedAt: time.Now().Unix(),
Events: events,
Warnings: warnings,
Skipped: skipped,
SkippedOther: skippedOther,
}, nil
}