Files
arcrun-collector/safewrite.go
Leo 2f33324d3f fix(collector): #60 監看的是筆記庫底下的子資料夾時,保護整個失效
真正的根因不是「vault 判斷漏了子庫」,是**判斷的方向搞反了**:
前兩輪問的都是「監看根**這一層**是不是 vault」,但 daemon 的產物一律落在
監看根底下——這兩件事只有在「監看根 == 庫根」時才等價,而那正好是前兩輪
唯一測過、也唯一不會出事的擺法。

使用者只要把庫底下的某一層加進監看(`KB/docs`、`KB/pages`、Obsidian 庫裡的
某個專案夾——很自然的用法),DetectVaultType 就回 VaultNone,整套保護退回
一般資料夾模式,卡片落在 `<監看根>/system-dev/wiki/cards/`:那個路徑就在
使用者的 graph 裡面,而且看得見,Logseq/Obsidian 每一張卡都收編成一頁。
前綴(第二輪)只擋得住撞名,擋不住「多出一堆機器頁」。

改法:把「這一層是不是庫」與「我寫的東西會不會落進誰的庫」拆成兩個判準。

- vault.go:新增 DetectVaultContext(往上找到最近的庫根)與 VaultDirUnder
  (往下擋:寫入目標會不會踩進子庫)。DetectVaultType 一字未改,繼續與
  install.sh 對齊——往上找用較嚴的判準(logseq/ 要有 config.edn 或
  journals//pages/ 佐證),因為那是替使用者猜、而且一次猜好幾層。
  停在家目錄與檔案系統根,避免 `~/logseq` 這種常見資料夾把整個家目錄判成庫。
- extract.go:cardsRelDirFor 改用 DetectVaultContext。
- safewrite.go:落卡前過 ensureWritable 機械閘——目標踩進子庫就中止,
  不靜靜寫進去。今天不會觸發,它防的是以後新增的寫檔點。
- tidy.go:收拾判準從「有沒有帶標記」擴充成「位置對不對 + 有沒有帶標記」,
  舊版留在看得見位置的卡會被搬進隱藏目錄;MigrateCardNames 每輪自動做,
  使用者不必下任何指令。報告多一個 VaultRoot,說清楚是誰的庫。

leo 派工單上的線索(庫在監看根**底下**)實測不成立:產物一律錨在監看根,
不會落進子庫。但那個「本來就沒破」原本沒有任何機制保證,所以照樣把兩種
格式的子庫情境永久寫進測試,加上 ensureWritable 當第二道保險。

驗證缺口(票上第 6 條):第二輪的足跡測試方向是對的,漏的是**觀測窗**——
snapshotTree 只拍監看根,而災情發生在監看根外面、庫裡面;且 fixture 只有
`root := vault` 一種擺法,測試與被測程式犯了同一個假設,所以永遠是綠的。
vault_subdir_test.go 把快照邊界改成筆記庫,並把「監看根與庫根的關係」升格
成測試維度(庫在上/庫在下/庫就是它/沒有庫 × Logseq/Obsidian)。

全程只用 t.TempDir() 與 mktemp -d;沒碰任何真實筆記庫、沒重啟任何 daemon。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 18:23:49 +08:00

67 lines
3.1 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.
// safewrite.go — 落卡寫檔前的保護(arcrun-rag#60 第二條:不得無條件覆蓋既有檔案)。
//
// 修前:extract_workersai.go / extract_gemma.go 都是無條件 os.WriteFile——不看目標
// 存不存在、不備份、不詢問。這次沒出事只是檔名剛好沒撞上,不代表安全。
package collector
import (
"bytes"
"fmt"
"os"
"path/filepath"
"time"
)
// safeWriteCard 把卡片內容寫到 dest,寫之前先過兩道:
//
// ① **不准踩進使用者的子筆記庫**(arcrun-rag#60 第三輪)——見下方 ensureWritable。
// ② 目標已存在時不無條件覆蓋:
// - 不存在 → 直接寫(首次落卡,行為不變)。
// - 存在且內容相同 → 不動它(冪等:同一份卡片重複萃取不該產生垃圾備份)。
// - 存在但內容不同 → 先把既有內容備份成 `<dest>.bak-<unixnano>`,備份成功才覆寫;
// 備份失敗就整個中止,寧可這次萃取失敗,也不要無聲蓋掉使用者機器上已經有的東西。
func safeWriteCard(absRoot, dest string, content []byte) error {
if err := ensureWritable(absRoot, dest); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return fmt.Errorf("建立卡片目錄失敗:%w", err)
}
existing, err := os.ReadFile(dest)
switch {
case err == nil:
if bytes.Equal(existing, content) {
return nil // 內容沒變,不必重寫也不必備份
}
backup := fmt.Sprintf("%s.bak-%d", dest, time.Now().UnixNano())
if werr := os.WriteFile(backup, existing, 0o644); werr != nil {
return fmt.Errorf("寫卡前備份既有檔失敗,中止避免覆蓋(%s):%w", dest, werr)
}
case os.IsNotExist(err):
// 目標不存在,正常首次落卡,不必備份。
default:
return fmt.Errorf("檢查既有卡片失敗(%s):%w", dest, err)
}
return os.WriteFile(dest, content, 0o644)
}
// ensureWritable 是「不准把機器產物寫進使用者的子筆記庫」這條規約的機械閘。
//
// 監看根**自己**在不在 vault 裡,由 cardsRelDirFor 決定落點(隱藏目錄)已經處理掉;
// 這裡擋的是另一半:目標路徑中途經過某個**子**筆記庫。今天所有寫檔點的目標都錨在
// 監看根,所以這道閘不會觸發——它的價值在以後:任何人新增一個「寫在原稿旁邊」的
// 寫檔點,會在這裡當場失敗,而不是在陌生人的筆記庫裡被發現。
//
// 失敗而不是靜靜改寫到別處:票上的紅線是「寧可少寫一個檔,也不要多寫一個可能被誤認的檔」,
// 而且錯誤訊息會沿著 DirectResult 冒到使用者面前,比默默換位置更容易被修掉。
func ensureWritable(absRoot, dest string) error {
if absRoot == "" {
return nil // 呼叫端沒有監看根概念(例如單元測試直寫),不在本閘範圍
}
if vdir, vt := VaultDirUnder(absRoot, dest); vt != VaultNone {
return fmt.Errorf("拒絕寫入 %s:這個位置在使用者的 %s 筆記庫裡(%s),"+
"機器產物不得寫進筆記庫的命名空間(arcrun-rag#60", dest, vt, vdir)
}
return nil
}