| 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 |