返回 DeepSeek-Reasonix
credential_diagnostics.go
根目录 / internal / config / credential_diagnostics.go
1 package config
2
3 import (
4 "fmt"
5 "os"
6 "path/filepath"
7 "strings"
8 )
9
10 type CredentialDiagnosticOptions struct {
11 Probe bool `json:"probe"`
12 Repair bool `json:"repair"`
13 DryRun bool `json:"dryRun"`
14 }
15
16 type CredentialDiagnosticCheck struct {
17 ID string `json:"id"`
18 Status string `json:"status"`
19 Path string `json:"path,omitempty"`
20 Message string `json:"message,omitempty"`
21 }
22
23 type CredentialDiagnosticReport struct {
24 Home string `json:"home"`
25 CredentialPath string `json:"credentialPath"`
26 Checks []CredentialDiagnosticCheck `json:"checks"`
27 PendingTransactions int `json:"pendingTransactions"`
28 Actions []string `json:"actions"`
29 }
30
31 func (r *CredentialDiagnosticReport) add(id, status, path, message string) {
32 r.Checks = append(r.Checks, CredentialDiagnosticCheck{ID: id, Status: status, Path: path, Message: message})
33 }
34
35 func pendingModelCredentialTransactionCount() (int, error) {
36 dir := modelCredentialTransactionDir()
37 entries, err := os.ReadDir(dir)
38 if os.IsNotExist(err) || dir == "" {
39 return 0, nil
40 }
41 if err != nil {
42 return 0, err
43 }
44 count := 0
45 for _, entry := range entries {
46 if !entry.IsDir() && filepath.Ext(entry.Name()) == ".json" {
47 count++
48 }
49 }
50 return count, nil
51 }
52
53 // DiagnoseCredentials provides the shared Desktop/CLI credential report.
54 func DiagnoseCredentials(opts CredentialDiagnosticOptions) (CredentialDiagnosticReport, error) {
55 home, path := ReasonixHomeDir(), UserCredentialsPath()
56 report := CredentialDiagnosticReport{Home: home, CredentialPath: path, Checks: []CredentialDiagnosticCheck{}, Actions: []string{}}
57 if strings.TrimSpace(home) == "" || strings.TrimSpace(path) == "" {
58 report.add("location", "failed", path, "Reasonix home could not be resolved")
59 return report, nil
60 }
61 homeAbs, homeErr := filepath.Abs(home)
62 pathAbs, pathErr := filepath.Abs(path)
63 if homeErr != nil || pathErr != nil || !pathWithinRoot(homeAbs, pathAbs) {
64 report.add("location", "failed", path, "credential path is outside Reasonix home")
65 return report, nil
66 }
67 report.add("location", "passed", path, "credential path is inside Reasonix home")
68
69 info, err := os.Lstat(path)
70 exists := err == nil
71 if os.IsNotExist(err) {
72 report.add("file_type", "passed", path, "credential file does not exist yet")
73 } else if err != nil {
74 report.add("file_type", "failed", path, classifyCredentialAccessError(err))
75 } else if info.Mode()&os.ModeSymlink != 0 {
76 report.add("file_type", "failed", path, "credential path is a symbolic link")
77 } else if !info.Mode().IsRegular() {
78 report.add("file_type", "failed", path, "credential path is not a regular file")
79 } else {
80 report.add("file_type", "passed", path, "regular file")
81 }
82
83 ownerCurrent, readonly, reparse := false, false, false
84 if exists {
85 owner, current, ro, rp, inspectErr := credentialPlatformInspect(path, info)
86 ownerCurrent, readonly, reparse = current, ro, rp
87 if inspectErr != nil {
88 report.add("owner", "unknown", path, inspectErr.Error())
89 } else if current {
90 report.add("owner", "passed", path, owner)
91 } else {
92 report.add("owner", "failed", path, owner)
93 }
94 if reparse {
95 report.add("reparse_point", "failed", path, "credential file is a reparse point")
96 } else {
97 report.add("reparse_point", "passed", path, "not a reparse point")
98 }
99 if readonly {
100 report.add("writable", "failed", path, "credential file is read-only")
101 } else {
102 report.add("writable", "unknown", path, "not read-only; effective write and replace access has not been tested")
103 }
104 if !info.Mode().IsRegular() || reparse {
105 report.add("read", "not_checked", path, "refusing to read a link or special file")
106 } else if _, readErr := os.ReadFile(path); readErr != nil {
107 report.add("read", "failed", path, classifyCredentialAccessError(readErr))
108 } else {
109 report.add("read", "passed", path, "credential file is readable")
110 }
111 } else {
112 report.add("owner", "not_checked", path, "credential file does not exist")
113 report.add("read", "not_checked", path, "credential file does not exist")
114 report.add("writable", "not_checked", path, "credential file does not exist")
115 report.add("reparse_point", "not_checked", path, "credential file does not exist")
116 }
117
118 pending, pendingErr := pendingModelCredentialTransactionCount()
119 report.PendingTransactions = pending
120 if pendingErr != nil {
121 report.add("transactions", "unknown", modelCredentialTransactionDir(), pendingErr.Error())
122 } else if pending > 0 {
123 report.add("transactions", "failed", modelCredentialTransactionDir(), fmt.Sprintf("%d unfinished model credential transaction(s)", pending))
124 } else {
125 report.add("transactions", "passed", modelCredentialTransactionDir(), "no unfinished model credential transactions")
126 }
127
128 var repairPathErr error
129 if opts.Repair && exists {
130 repairPathErr = validateCredentialRepairPath(home, path, info)
131 }
132 if opts.Probe && repairPathErr == nil {
133 probeCredentialDirectory(&report, path)
134 } else {
135 report.add("directory_replace_probe", "not_checked", filepath.Dir(path), "run with --probe to test create and rename")
136 }
137
138 if opts.Repair {
139 if repairPathErr != nil {
140 report.add("repair", "failed", path, repairPathErr.Error())
141 } else if !exists || info == nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || reparse || !ownerCurrent {
142 report.add("repair", "failed", path, "repair requires a current-user-owned regular file inside Reasonix home")
143 } else if opts.DryRun {
144 report.Actions = append(report.Actions, "would clear read-only state and add current-user read/write/replace access where required")
145 report.add("repair", "passed", path, "repair preview only")
146 } else {
147 actions, repairErr := repairCredentialTarget(home, path, info)
148 if repairErr != nil {
149 report.add("repair", "failed", path, repairErr.Error())
150 } else {
151 report.Actions = append(report.Actions, actions...)
152 report.add("repair", "passed", path, "credential read/write access verified; replacement was not attempted")
153 }
154 }
155 }
156 return report, nil
157 }
158
159 func probeCredentialDirectory(report *CredentialDiagnosticReport, path string) {
160 dir := filepath.Dir(path)
161 if err := os.MkdirAll(dir, 0o700); err != nil {
162 report.add("directory_replace_probe", "failed", dir, classifyCredentialAccessError(err))
163 return
164 }
165 tmp, err := os.CreateTemp(dir, ".reasonix-credential-probe-*")
166 if err != nil {
167 report.add("directory_replace_probe", "failed", dir, classifyCredentialAccessError(err))
168 return
169 }
170 from := tmp.Name()
171 _ = tmp.Close()
172 to := from + ".renamed"
173 defer os.Remove(from)
174 defer os.Remove(to)
175 if err := os.Rename(from, to); err != nil {
176 report.add("directory_replace_probe", "failed", dir, classifyCredentialAccessError(err))
177 return
178 }
179 report.add("directory_replace_probe", "passed", dir, "temporary create and atomic rename succeeded; the existing .env was not replaced")
180 }
181
182 func probeCredentialTarget(path string) error {
183 // Opening without CREATE/TRUNC tests access without reading or rewriting
184 // secrets. In particular it cannot overwrite a concurrent slot publication.
185 f, err := os.OpenFile(path, os.O_RDWR, 0)
186 if err != nil {
187 return err
188 }
189 return f.Close()
190 }
191
192 func validateCredentialRepairPath(home, path string, expected os.FileInfo) error {
193 home, err := filepath.Abs(home)
194 if err != nil {
195 return err
196 }
197 path, err = filepath.Abs(path)
198 if err != nil {
199 return err
200 }
201 if !pathWithinRoot(home, path) {
202 return fmt.Errorf("credential path is outside Reasonix home")
203 }
204 // Include the home itself: Lstat on .env alone misses linked directories.
205 for current := path; ; current = filepath.Dir(current) {
206 info, err := os.Lstat(current)
207 if err != nil {
208 return err
209 }
210 _, _, _, reparse, err := credentialPlatformInspect(current, info)
211 if err != nil {
212 return err
213 }
214 if info.Mode()&os.ModeSymlink != 0 || reparse {
215 return fmt.Errorf("repair refuses linked paths or reparse points")
216 }
217 if current == path && (!info.Mode().IsRegular() || !os.SameFile(expected, info)) {
218 return fmt.Errorf("credential file identity changed")
219 }
220 if current == home {
221 break
222 }
223 }
224 return nil
225 }
226
227 func repairCredentialTarget(home, path string, expected os.FileInfo) ([]string, error) {
228 if err := validateCredentialRepairPath(home, path, expected); err != nil {
229 return nil, err
230 }
231 unlock, err := LockUserCredentialEdits()
232 if err != nil {
233 return nil, err
234 }
235 defer unlock()
236 if err := validateCredentialRepairPath(home, path, expected); err != nil {
237 return nil, err
238 }
239 return credentialPlatformRepair(path, expected, func() error {
240 if err := validateCredentialRepairPath(home, path, expected); err != nil {
241 return err
242 }
243 return probeCredentialTarget(path)
244 })
245 }
246
247 func classifyCredentialAccessError(err error) string {
248 if err == nil {
249 return ""
250 }
251 if os.IsPermission(err) {
252 return "current user does not have the required access"
253 }
254 return err.Error()
255 }
256
256 lines GO