| 1 | package config |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "slices" |
| 8 | "strings" |
| 9 | |
| 10 | "github.com/BurntSushi/toml" |
| 11 | |
| 12 | fileencoding "reasonix/internal/fileutil/encoding" |
| 13 | "reasonix/internal/permission" |
| 14 | "reasonix/internal/sandbox" |
| 15 | ) |
| 16 | |
| 17 | // PersistProjectWriteAccess updates [permissions].allow and [sandbox].allow_write |
| 18 | // in one locked, validated, atomic write. permRule may be empty when ordinary |
| 19 | // permission is already allowed. |
| 20 | func PersistProjectWriteAccess(path string, dirs []string, permRule string) error { |
| 21 | path = strings.TrimSpace(path) |
| 22 | if path == "" { |
| 23 | return fmt.Errorf("persist write access: empty config path") |
| 24 | } |
| 25 | unlock, err := LockConfigFileEdits(path) |
| 26 | if err != nil { |
| 27 | return err |
| 28 | } |
| 29 | defer unlock() |
| 30 | |
| 31 | resolved, exists, err := statConfigPath(path) |
| 32 | if err != nil { |
| 33 | return err |
| 34 | } |
| 35 | var raw []byte |
| 36 | if exists { |
| 37 | raw, err = fileencoding.ReadFileUTF8(resolved) |
| 38 | if err != nil { |
| 39 | return err |
| 40 | } |
| 41 | } |
| 42 | body := string(raw) |
| 43 | edit, err := loadForEditStrict(path, true, false) |
| 44 | if err != nil { |
| 45 | return err |
| 46 | } |
| 47 | |
| 48 | home, _ := os.UserHomeDir() |
| 49 | allowWrite := append([]string(nil), edit.Sandbox.AllowWrite...) |
| 50 | for _, dir := range dirs { |
| 51 | formatted := sandbox.FormatConfigWritePath(dir, home) |
| 52 | if formatted == "" { |
| 53 | continue |
| 54 | } |
| 55 | if writeRootCovered(allowWrite, formatted, home) { |
| 56 | continue |
| 57 | } |
| 58 | allowWrite = append(allowWrite, formatted) |
| 59 | } |
| 60 | |
| 61 | allow := append([]string(nil), edit.Permissions.Allow...) |
| 62 | if rule := strings.TrimSpace(permRule); rule != "" { |
| 63 | if coveredBy := coveredPermissionRule(allow, rule); coveredBy == "" { |
| 64 | allow = pruneCoveredPermissionRules(allow, rule) |
| 65 | allow = append(allow, rule) |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | if body == "" { |
| 70 | body = fmt.Sprintf("[permissions]\nallow = %s\n\n[sandbox]\nallow_write = %s\n", renderStringArray(allow), renderStringArray(allowWrite)) |
| 71 | } else { |
| 72 | body = upsertTOMLSectionKey(body, "permissions", "allow", "allow = "+renderStringArray(allow)) |
| 73 | body = upsertTOMLSectionKey(body, "sandbox", "allow_write", "allow_write = "+renderStringArray(allowWrite)) |
| 74 | } |
| 75 | |
| 76 | var candidate Config |
| 77 | if _, err := toml.Decode(body, &candidate); err != nil { |
| 78 | return fmt.Errorf("persist write access: validate updated config: %w", err) |
| 79 | } |
| 80 | if !slices.Equal(candidate.Permissions.Allow, allow) { |
| 81 | return fmt.Errorf("persist write access: validate updated allow: got %v, want %v", candidate.Permissions.Allow, allow) |
| 82 | } |
| 83 | if !slices.Equal(candidate.Sandbox.AllowWrite, allowWrite) { |
| 84 | return fmt.Errorf("persist write access: validate updated allow_write: got %v, want %v", candidate.Sandbox.AllowWrite, allowWrite) |
| 85 | } |
| 86 | return writeConfigFileResolved(resolved, body, configFilePerm(path)) |
| 87 | } |
| 88 | |
| 89 | func writeRootCovered(existing []string, candidate, home string) bool { |
| 90 | candAbs := expandPersistedWritePath(candidate, home) |
| 91 | for _, item := range existing { |
| 92 | existAbs := expandPersistedWritePath(item, home) |
| 93 | if existAbs == "" || candAbs == "" { |
| 94 | if item == candidate { |
| 95 | return true |
| 96 | } |
| 97 | continue |
| 98 | } |
| 99 | if sandbox.PathWithin(existAbs, candAbs) { |
| 100 | return true |
| 101 | } |
| 102 | } |
| 103 | return false |
| 104 | } |
| 105 | |
| 106 | func expandPersistedWritePath(raw, home string) string { |
| 107 | raw = strings.TrimSpace(raw) |
| 108 | if raw == "" { |
| 109 | return "" |
| 110 | } |
| 111 | abs, _, err := sandbox.NormalizeWriteDir(raw, "", home) |
| 112 | if err != nil { |
| 113 | if filepath.IsAbs(raw) { |
| 114 | return filepath.Clean(raw) |
| 115 | } |
| 116 | return raw |
| 117 | } |
| 118 | return abs |
| 119 | } |
| 120 | |
| 121 | func coveredPermissionRule(existing []string, candidate string) string { |
| 122 | for _, item := range existing { |
| 123 | if permission.RuleCoversString(item, candidate) { |
| 124 | return item |
| 125 | } |
| 126 | } |
| 127 | return "" |
| 128 | } |
| 129 | |
| 130 | func pruneCoveredPermissionRules(existing []string, candidate string) []string { |
| 131 | out := make([]string, 0, len(existing)) |
| 132 | for _, item := range existing { |
| 133 | if permission.RuleCoversString(candidate, item) && item != candidate { |
| 134 | continue |
| 135 | } |
| 136 | out = append(out, item) |
| 137 | } |
| 138 | return out |
| 139 | } |
| 140 |