2f33324d3f
真正的根因不是「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>
67 lines
3.1 KiB
Go
67 lines
3.1 KiB
Go
// 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
|
||
}
|