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(不再夾帶第二份完整程式)
370 lines
13 KiB
Go
370 lines
13 KiB
Go
// scan.go — 掃描迴圈與差異分類(SDD ingest-hash-trigger design §3)。
|
||
// 事件順序:先把本輪 removed×added 以 content_hash 配對成 renamed(只更新路徑映射),
|
||
// 再分類其餘 added/modified/removed;removed 數 > 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.2,2026-08-06):
|
||
// 下面那道 `!allowedExt[...]` 的閘**直接 return nil**——不進事件、不進 manifest、
|
||
// 不進 status、不進畫面。使用者把一份 `.doc` 丟進資料夾,**整個系統從頭到尾一個字都不說**,
|
||
// 他只會覺得「這東西壞了」。G-6.2 的判準是:要嘛查得到,**要嘛當場被告知不支援**;
|
||
// 「安靜地略過」不是可接受的第三種結果。
|
||
//
|
||
// 為什麼是白名單、而不是「非 allowedExt 一律點名」:後者在 Obsidian 附件庫(幾百張 .png)
|
||
// 或程式碼資料夾裡會炸出幾百行「處理不了」=噪音,使用者反而學會忽略整塊訊息。
|
||
// ⇒ **像文件的逐檔點名,其餘只報一個總數**(見 Scan 的 SkippedOther)。兩種都不沉默,
|
||
// 但只有前者值得佔用他的注意力。
|
||
//
|
||
// 加新格式的順序:先列在這裡(使用者立刻看得到「還不支援」),
|
||
// 等 convert.go 真的接上抽取器,再把它從這裡搬去 allowedExt。
|
||
var docLikeExt = map[string]bool{
|
||
// 舊版 Office(OLE2 二進位,與 .docx/.xlsx/.pptx 是完全不同的格式)
|
||
".doc": true, ".xls": true, ".ppt": true,
|
||
// Apple iWork
|
||
".pages": true, ".numbers": true, ".key": true,
|
||
// OpenDocument(LibreOffice)
|
||
".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.2,2026-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-path:mtime+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) 先配對 renamed(design §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) modified:manifest 有、現況有、content_hash != ingested_hash(design §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) 更新 manifest(rebuild):現況檔全數收錄;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
|
||
}
|