返回 DeepSeek-Reasonix
credential_acl_repair.go
根目录 / internal / config / credential_acl_repair.go
1 package config
2
3 import (
4 "errors"
5 "fmt"
6 "io/fs"
7 "log/slog"
8 "os"
9 "path/filepath"
10 "strings"
11 "time"
12
13 fileencoding "reasonix/internal/fileutil/encoding"
14 "reasonix/internal/winaclresidue"
15 )
16
17 // Test seams for the writer-side recovery ladder.
18 var (
19 credentialStoreReset = winaclresidue.ResetCredentialDACL
20 credentialStoreQuarantine = quarantineCredentialStore
21 )
22
23 // readCredentialFile repairs a denied read of the global credential store
24 // only when a retired-sandbox marker proves the deny came from Reasonix. The
25 // original permission error stays the cause so callers keep their semantics.
26 func readCredentialFile(path string) ([]byte, error) {
27 data, err := os.ReadFile(path)
28 if err == nil || !os.IsPermission(err) || runtimeGOOS != "windows" || !isGlobalCredentialPath(path) {
29 return data, err
30 }
31 if repairErr := winaclresidue.RepairLegacyCredentialDeny(credentialRepairPath(path)); repairErr != nil {
32 slog.Warn("config: legacy credential ACL repair failed", "path", path, "err", repairErr)
33 return nil, fmt.Errorf("%w (legacy ACL repair: %w)", err, repairErr)
34 }
35 return os.ReadFile(path)
36 }
37
38 // readCredentialFileForWrite serves an explicit save. Saving must not depend
39 // on proving who locked the file: after the provenance-checked repair it
40 // resets the DACL without reading it and, failing that, moves the locked file
41 // aside and starts a fresh store. The old file stays next to it for recovery.
42 func readCredentialFileForWrite(path string) ([]byte, error) {
43 data, err := readCredentialFile(path)
44 if err == nil || !errors.Is(err, fs.ErrPermission) || runtimeGOOS != "windows" || !isGlobalCredentialPath(path) {
45 return data, err
46 }
47 repairPath := credentialRepairPath(path)
48 if resetErr := credentialStoreReset(repairPath); resetErr != nil {
49 slog.Warn("config: credential store ACL reset failed", "path", path, "err", resetErr)
50 } else if data, readErr := os.ReadFile(path); readErr == nil {
51 slog.Warn("config: reset the credential store ACL to the current user", "path", path)
52 return data, nil
53 }
54 quarantined, quarantineErr := credentialStoreQuarantine(repairPath)
55 if quarantineErr != nil {
56 return nil, fmt.Errorf("%w; the credential store could not be reset or moved aside: %w", err, quarantineErr)
57 }
58 slog.Warn("config: moved the locked credential store aside and started a new one", "path", path, "quarantined", quarantined)
59 return nil, nil
60 }
61
62 func isGlobalCredentialPath(path string) bool {
63 credentials := strings.TrimSpace(UserCredentialsPath())
64 return path != "" && credentials != "" && samePath(path, credentials)
65 }
66
67 // credentialRepairPath resolves links because older Windows builds recorded
68 // the canonical path before writing a deny marker. Only the denied path is
69 // resolved so ordinary reads stay free of the extra filesystem round trip.
70 func credentialRepairPath(path string) string {
71 if real, err := filepath.EvalSymlinks(path); err == nil {
72 return real
73 }
74 return path
75 }
76
77 // quarantineCredentialStore keeps the locked file beside the store under a
78 // timestamped name. The move needs DELETE on the file plus directory rights,
79 // neither of which a read deny removes.
80 func quarantineCredentialStore(path string) (string, error) {
81 target := path + ".locked-" + time.Now().UTC().Format("20060102-150405")
82 if err := winaclresidue.RenameLockedFile(path, target); err != nil {
83 return "", err
84 }
85 return target, nil
86 }
87
88 func readCredentialFileLines(path string) ([]string, error) {
89 return splitCredentialLines(readCredentialFile(path))
90 }
91
92 // readCredentialFileLinesForWrite is the writer-side reader: an explicit save
93 // may recover a locked store, which plain reads must never attempt.
94 func readCredentialFileLinesForWrite(path string) ([]string, error) {
95 return splitCredentialLines(readCredentialFileForWrite(path))
96 }
97
98 func splitCredentialLines(data []byte, err error) ([]string, error) {
99 if err != nil {
100 if os.IsNotExist(err) {
101 return nil, nil
102 }
103 return nil, err
104 }
105 text := strings.TrimRight(string(fileencoding.DecodeToUTF8(data)), "\n")
106 if text == "" {
107 return nil, nil
108 }
109 return strings.Split(text, "\n"), nil
110 }
111
111 lines GO