fix(t213): 診斷檔遮蔽引擎錯誤訊息裡的本機絕對路徑
engine.detail/engine.last_error 在同步引擎沒在跑時,會把 collector 開機 橫幅的完整原文帶出來(direct.go RunDirect 的「監看 %s → %s」那行),其中 含監看資料夾的絕對路徑(如 /Users/xxx/Desktop/...)。這是上一輪(第二/ 三輪)已發現但標記「附帶發現、未修」的洞——首頁在同一狀態下本來就會顯示 同一句話(不動,留給另案判斷),但診斷檔現在會被匯出成檔案交給外部人看, 風險層級跟留在托盤畫面上不同,這輪把它遮掉。 新增 redactLocalPaths(),只在 buildDiagnosticsPayload() 組裝 engine 欄位時 套用:把訊息裡看起來像本機絕對路徑的片段換成「…/<路徑最後一截>」, http(s) URL 先放行原文(避免 URL 裡的 / 被誤判成本機路徑)。只動 diagnostics_export.go 這一端,不改 describeStatus()/collectorFailure()/ direct.go 的訊息本身——那些同時是首頁托盤畫面在用的同一組憑據。 擴充 diagnostics_engine_e2e_test.go(真執行檔+真 supervisor 子行程,零 網路零帳號):監看資料夾改用真實絕對路徑,證明子行程停擺後整份匯出 JSON 找不到該路徑,同時 last_error/detail 仍讀得出「引擎沒在跑、以及為什麼」 (含監看資料夾的名字)。另加 8 個 redactLocalPaths 純函式單測。 go build/go vet/go test ./...(collector+arcrun-app+supervisor 三個 package)全綠;diagnostics_stage_manual_test.go 維持預設 SKIP,本輪未碰 網路/stage。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,7 @@ package main
|
||||
// 行程本體,本機這半直接讀 status.json,雲端那半改打新增的 X-Arcrun-API-Key 版
|
||||
// 端點(GET /portal/daemon/diagnostics,免帳密,daemon 背景行程沒有 portal session)。
|
||||
//
|
||||
// 🔴 leo 08-08 三條追加規則:
|
||||
// 🔴 leo 08-08 四條追加規則:
|
||||
// ① 首頁與診斷檔必須是同一組數字——本檔**只讀** status.json 裡 t210 已經算好的
|
||||
// Progress/FailureBreakdown,不重新掃 manifest、不另算一套。
|
||||
// ② 分類名稱字串只准住在 collector/progress.go 的 ClassifyFailure——本檔原樣照抄
|
||||
@@ -18,6 +18,10 @@ package main
|
||||
// ③ 失敗檔名只出 basename,不出完整路徑(完整路徑會洩漏使用者的資料夾結構)——
|
||||
// 沿用首頁既有的 buildSkipped():它的 Files 欄位本來就是 filepath.Base()+白話標籤,
|
||||
// 不是原始路徑,這裡直接借用,不重寫第二套。
|
||||
// ④(t213 phase 4,08-08 補)engine.detail/engine.last_error 一樣不准出現本機絕對
|
||||
// 路徑——同③的理由,只是這次的路徑來源是 supervisor 捕到的 stderr 末行(collector
|
||||
// 啟動時印的「監看 <資料夾路徑>」那行),不是檔名。做法見 redactLocalPaths():換成
|
||||
// 「…/<路徑最後一截>」,不是整句吃掉——「引擎沒在跑、以及為什麼」仍要答得出來。
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -161,10 +165,15 @@ func (a *App) buildDiagnosticsPayload() exportedDiagnostics {
|
||||
Syncing: collectorSyncing(),
|
||||
CrashLooping: looping,
|
||||
Restarts: restarts,
|
||||
Headline: headline,
|
||||
Detail: detail,
|
||||
// t213 phase 4(leo 08-08 追加):headline/detail 沿用首頁同一套 describeStatus(),
|
||||
// 但 detail 在 !alive 時會把 collectorFailure() 的死因原文接進來(見 describeStatus
|
||||
// 的「原因:」那幾行)——那句死因常常就是 supervisor 捕到的 stderr 末行,可能帶著
|
||||
// 監看資料夾的本機絕對路徑(見下面 redactLocalPaths 的長註解)。這裡只遮蔽「要匯出
|
||||
// 成檔案」的這一份,describeStatus() 本身/首頁托盤畫面完全不動。
|
||||
Headline: redactLocalPaths(headline),
|
||||
Detail: redactLocalPaths(detail),
|
||||
}
|
||||
engine.LastError = engineLastErrorFor(alive, looping, lastErr)
|
||||
engine.LastError = redactLocalPaths(engineLastErrorFor(alive, looping, lastErr))
|
||||
|
||||
cfg, _ := loadCfg()
|
||||
accounts := make([]accountDiagnostics, 0, len(cfg.Accounts))
|
||||
@@ -201,6 +210,138 @@ func engineLastErrorFor(alive, crashLooping bool, lastErr string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// redactLocalPaths — t213 phase 4(leo 08-08 追加規則④)。
|
||||
//
|
||||
// 背景:`engine.detail`/`engine.last_error` 的內容有一部分來自 collectorFailure() 的
|
||||
// 死因原文,那句話是 supervisor 捕到的 stderr **末行**(見 supervisor.go runOnce 的
|
||||
// stderr scanner)——常見的情況是子行程剛啟動就掛,最後一行 stderr 停在
|
||||
// direct.go RunDirect 印的開機橫幅:「collector direct daemon 啟動:監看 <資料夾絕對路徑>
|
||||
// → <目的地>」,也可能是任何 os.PathError 形狀的訊息(如「open /Users/x/y: permission
|
||||
// denied」)。兩種形狀都以本機絕對路徑開頭——這條路徑會洩漏使用者的目錄結構(含帳號名,
|
||||
// 例如 /Users/<帳號>/... 或 C:\Users\<帳號>\...),跟既有的「失敗檔名只出 basename」
|
||||
// (見 buildSkipped())是同一個理由,只是換了個欄位發作。
|
||||
//
|
||||
// 怎麼遮:只挖掉「路徑本體」,換成「…/<路徑最後一截>」——資料夾/檔名本身保留,
|
||||
// 訊息的其餘文字原封不動。這樣「引擎沒在跑、以及為什麼」依然讀得懂(不能把資訊遮成啞巴),
|
||||
// 只是看不出這是使用者電腦上的哪個帳號、哪層目錄。
|
||||
//
|
||||
// 只在這裡(診斷檔匯出的最後一哩)做,不改 direct.go/supervisor.go 的訊息本身,也不改
|
||||
// describeStatus()/collectorFailure():那兩支同時也是首頁托盤畫面在用的同一組憑據,
|
||||
// 動了會連首頁畫面一起變——超出本次「只動診斷檔這一端」的範圍(首頁要不要一起改,
|
||||
// 留給總管/leo 另外決定,見交接紀錄)。
|
||||
func redactLocalPaths(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
r := []rune(s)
|
||||
n := len(r)
|
||||
var b strings.Builder
|
||||
for i := 0; i < n; {
|
||||
// http(s) URL 先放行,原文照抄——避免其中的 `/`(如 .../webhooks/named/...)
|
||||
// 被下面的「本機路徑」判斷誤吃(URL 不是本機目錄結構,不在遮蔽範圍內)。
|
||||
if j, ok := urlSpanAt(r, i); ok {
|
||||
b.WriteString(string(r[i:j]))
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
if plen := absPathPrefixLen(r, i); plen > 0 {
|
||||
start := i
|
||||
j := i + plen
|
||||
for j < n && !isPathBoundary(r[j]) {
|
||||
j++
|
||||
}
|
||||
b.WriteString("…/")
|
||||
b.WriteString(lastPathSegment(string(r[start:j])))
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r[i])
|
||||
i++
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// urlSpanAt 偵測 i 處是不是 http(s) URL 的起點(必須在字詞邊界上,避免咬到中間),
|
||||
// 回傳 URL 一路唸到下一個 URL 邊界字元為止的結尾位置。
|
||||
//
|
||||
// 🔴 這裡不能直接用 isPathBoundary 當終止點——URL 裡的 `://` scheme 分隔符與
|
||||
// `:port` 都合法含有冒號,若拿冒號當終止點,`https://` 掃到第一個 `:` 就會被腰斬成
|
||||
// 「https」,剩下的 `//host/path` 又會被 absPathPrefixLen 誤判成本機路徑(因為冒號
|
||||
// 本身是 isPathBoundary 認得的邊界字元,讓緊接在後面的 `/` 通過起點檢查)——
|
||||
// 這正是這支函式存在的理由,用專屬的終止點集合(isPathBoundary 拿掉冒號)。
|
||||
func urlSpanAt(r []rune, i int) (int, bool) {
|
||||
if i > 0 && !isPathBoundary(r[i-1]) {
|
||||
return 0, false
|
||||
}
|
||||
rest := string(r[i:min(i+8, len(r))])
|
||||
if !strings.HasPrefix(rest, "http://") && !strings.HasPrefix(rest, "https://") {
|
||||
return 0, false
|
||||
}
|
||||
j := i
|
||||
for j < len(r) && !isURLBoundary(r[j]) {
|
||||
j++
|
||||
}
|
||||
return j, true
|
||||
}
|
||||
|
||||
// isURLBoundary=isPathBoundary 拿掉冒號(URL 合法含冒號,見上面 urlSpanAt 的註解)。
|
||||
func isURLBoundary(r rune) bool {
|
||||
if r == ':' {
|
||||
return false
|
||||
}
|
||||
return isPathBoundary(r)
|
||||
}
|
||||
|
||||
// absPathPrefixLen 判斷 i 是不是一個本機絕對路徑的起點,回傳「路徑根部」本身的字元數
|
||||
// (Unix `/` 算 1 個;Windows 磁碟代號 `C:\`/`C:/` 算 3 個)。只在「前一個字元是邊界
|
||||
// 字元(或 i==0)」時才算數——避免把一般文字裡湊巧出現的字元誤判成路徑起點。
|
||||
func absPathPrefixLen(r []rune, i int) int {
|
||||
if i > 0 && !isPathBoundary(r[i-1]) {
|
||||
return 0
|
||||
}
|
||||
if r[i] == '/' {
|
||||
return 1
|
||||
}
|
||||
if i+2 < len(r) && isASCIILetter(r[i]) && r[i+1] == ':' && (r[i+2] == '\\' || r[i+2] == '/') {
|
||||
return 3
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func isASCIILetter(r rune) bool {
|
||||
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
|
||||
}
|
||||
|
||||
// isPathBoundary=這個字元不可能是路徑本體的一部分,可以拿來當「路徑到這裡結束」的判準
|
||||
// (空白、中西文標點、箭頭)。本檔目前已知的兩種訊息形狀都靠它正確截斷:
|
||||
// 「監看 A、B → url」用「、」「→」收尾;`os.PathError` 的「path: 原因」用「:」收尾。
|
||||
// 刻意不把純空白以外的一般文字字元(含資料夾名裡常見的空格)當終止點以外的東西處理——
|
||||
// 資料夾名稱裡的空格會讓比對在空格處提早停下,不會吃到後面不相干的文字,這是刻意的保守
|
||||
// 選擇:寧可少遮一點(漏出資料夾名稱片段),也不要多遮(把後面的說明文字一起吃掉)。
|
||||
func isPathBoundary(r rune) bool {
|
||||
switch r {
|
||||
case ' ', '\t', '\n', '\r',
|
||||
'、', '(', ')', '(', ')',
|
||||
',', ';', ',', ';', ':', '→',
|
||||
'"', '\'', '<', '>':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// lastPathSegment 取路徑最後一截(跨 `/`、`\` 兩種分隔符,不假設執行平台),
|
||||
// 拿不到任何一截(例如整條路徑只有根目錄)時回一個看得懂的佔位字,不留空字串。
|
||||
func lastPathSegment(p string) string {
|
||||
p = strings.TrimRight(p, `/\`)
|
||||
if idx := strings.LastIndexAny(p, `/\`); idx >= 0 {
|
||||
p = p[idx+1:]
|
||||
}
|
||||
if p == "" {
|
||||
return "本機路徑"
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// mergeDiagnostics 純函式:把已經各自拿到的本機/雲端資料組成最終輸出形狀,不做任何 IO。
|
||||
//
|
||||
// leo 08-08 規則對照:
|
||||
|
||||
Reference in New Issue
Block a user