| 1 | package sandbox |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "runtime" |
| 8 | "strings" |
| 9 | ) |
| 10 | |
| 11 | // NormalizeWriteDir expands a user- or model-supplied write directory into an |
| 12 | // absolute, symlink-resolved path plus a short display form. raw may be |
| 13 | // workspace-relative, start with ~, or contain ${HOME}. Globs are rejected. |
| 14 | func NormalizeWriteDir(raw, workDir, home string) (abs, display string, err error) { |
| 15 | raw = strings.TrimSpace(raw) |
| 16 | if raw == "" { |
| 17 | return "", "", fmt.Errorf("write directory is empty") |
| 18 | } |
| 19 | if writePathHasGlob(raw) { |
| 20 | return "", "", fmt.Errorf("write directory %q must be a concrete directory, not a glob", raw) |
| 21 | } |
| 22 | expanded, err := expandWritePath(raw, home) |
| 23 | if err != nil { |
| 24 | return "", "", err |
| 25 | } |
| 26 | if !filepath.IsAbs(expanded) { |
| 27 | base := strings.TrimSpace(workDir) |
| 28 | if base == "" { |
| 29 | base, err = os.Getwd() |
| 30 | if err != nil { |
| 31 | return "", "", fmt.Errorf("resolve write directory %q: %w", raw, err) |
| 32 | } |
| 33 | } |
| 34 | expanded = filepath.Join(base, expanded) |
| 35 | } |
| 36 | abs, err = ResolveAbsPath(expanded) |
| 37 | if err != nil { |
| 38 | return "", "", fmt.Errorf("resolve write directory %q: %w", raw, err) |
| 39 | } |
| 40 | return abs, DisplayWritePath(abs, home), nil |
| 41 | } |
| 42 | |
| 43 | func expandWritePath(raw, home string) (string, error) { |
| 44 | if strings.Contains(raw, "${HOME}") { |
| 45 | if strings.TrimSpace(home) == "" { |
| 46 | return "", fmt.Errorf("write directory %q uses ${HOME} but the home directory is unknown", raw) |
| 47 | } |
| 48 | raw = strings.ReplaceAll(raw, "${HOME}", home) |
| 49 | } |
| 50 | if raw == "~" || strings.HasPrefix(raw, "~/") || (runtime.GOOS == "windows" && strings.HasPrefix(raw, `~\`)) { |
| 51 | if strings.TrimSpace(home) == "" { |
| 52 | return "", fmt.Errorf("write directory %q uses ~ but the home directory is unknown", raw) |
| 53 | } |
| 54 | if raw == "~" { |
| 55 | return home, nil |
| 56 | } |
| 57 | return filepath.Join(home, raw[2:]), nil |
| 58 | } |
| 59 | return raw, nil |
| 60 | } |
| 61 | |
| 62 | func writePathHasGlob(raw string) bool { |
| 63 | return strings.ContainsAny(raw, "*?[") |
| 64 | } |
| 65 | |
| 66 | // ResolveAbsPath resolves path to an absolute, cleaned form. Because a write |
| 67 | // target need not exist yet, it resolves the deepest existing ancestor with |
| 68 | // EvalSymlinks and re-appends the not-yet-existing tail. |
| 69 | func ResolveAbsPath(path string) (string, error) { |
| 70 | abs, err := filepath.Abs(path) |
| 71 | if err != nil { |
| 72 | return "", err |
| 73 | } |
| 74 | abs = filepath.Clean(abs) |
| 75 | tail := "" |
| 76 | cur := abs |
| 77 | for { |
| 78 | if real, err := filepath.EvalSymlinks(cur); err == nil { |
| 79 | return filepath.Join(real, tail), nil |
| 80 | } |
| 81 | parent := filepath.Dir(cur) |
| 82 | if parent == cur { |
| 83 | return abs, nil |
| 84 | } |
| 85 | tail = filepath.Join(filepath.Base(cur), tail) |
| 86 | cur = parent |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | // DisplayWritePath returns a user-facing form such as ~/.local when abs sits |
| 91 | // under home. Other paths stay absolute. |
| 92 | func DisplayWritePath(abs, home string) string { |
| 93 | abs = canonicalDir(abs) |
| 94 | home = canonicalDir(home) |
| 95 | if abs == "" { |
| 96 | return "" |
| 97 | } |
| 98 | if home != "" && PathWithin(home, abs) { |
| 99 | rel, err := filepath.Rel(home, abs) |
| 100 | if err == nil { |
| 101 | if rel == "." { |
| 102 | return "~" |
| 103 | } |
| 104 | return "~/" + filepath.ToSlash(rel) |
| 105 | } |
| 106 | } |
| 107 | return abs |
| 108 | } |
| 109 | |
| 110 | // FormatConfigWritePath stores home-relative paths as ${HOME}/... and other |
| 111 | // paths as cleaned absolute paths. |
| 112 | func FormatConfigWritePath(abs, home string) string { |
| 113 | abs = canonicalDir(abs) |
| 114 | home = canonicalDir(home) |
| 115 | if abs == "" { |
| 116 | return "" |
| 117 | } |
| 118 | if home != "" && PathWithin(home, abs) { |
| 119 | rel, err := filepath.Rel(home, abs) |
| 120 | if err == nil { |
| 121 | if rel == "." { |
| 122 | return "${HOME}" |
| 123 | } |
| 124 | return "${HOME}/" + filepath.ToSlash(rel) |
| 125 | } |
| 126 | } |
| 127 | return abs |
| 128 | } |
| 129 | |
| 130 | // PathWithin reports whether path is at or below root. Both should be |
| 131 | // absolute and cleaned. |
| 132 | func PathWithin(root, path string) bool { |
| 133 | rel, err := filepath.Rel(root, path) |
| 134 | if err != nil { |
| 135 | return false |
| 136 | } |
| 137 | return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))) |
| 138 | } |
| 139 | |
| 140 | // CollapseWriteRoots deduplicates directories and drops children already |
| 141 | // covered by an ancestor. |
| 142 | func CollapseWriteRoots(dirs []string) []string { |
| 143 | cleaned := uniqueCleanDirs(dirs) |
| 144 | if len(cleaned) < 2 { |
| 145 | return cleaned |
| 146 | } |
| 147 | out := make([]string, 0, len(cleaned)) |
| 148 | for _, dir := range cleaned { |
| 149 | covered := false |
| 150 | for _, other := range cleaned { |
| 151 | if other == dir { |
| 152 | continue |
| 153 | } |
| 154 | if PathWithin(other, dir) { |
| 155 | covered = true |
| 156 | break |
| 157 | } |
| 158 | } |
| 159 | if !covered { |
| 160 | out = append(out, dir) |
| 161 | } |
| 162 | } |
| 163 | return out |
| 164 | } |
| 165 | |
| 166 | func uniqueCleanDirs(dirs []string) []string { |
| 167 | seen := make(map[string]bool, len(dirs)) |
| 168 | out := make([]string, 0, len(dirs)) |
| 169 | for _, dir := range dirs { |
| 170 | dir = filepath.Clean(strings.TrimSpace(dir)) |
| 171 | if dir == "" || dir == "." || seen[dir] { |
| 172 | continue |
| 173 | } |
| 174 | seen[dir] = true |
| 175 | out = append(out, dir) |
| 176 | } |
| 177 | return out |
| 178 | } |
| 179 | |
| 180 | // IsFilesystemRoot reports POSIX /, a Windows drive root, or a UNC share root. |
| 181 | func IsFilesystemRoot(abs string) bool { |
| 182 | raw := strings.TrimSpace(abs) |
| 183 | if raw == "" { |
| 184 | return false |
| 185 | } |
| 186 | if runtime.GOOS == "windows" { |
| 187 | // filepath.VolumeName(`\\`) is empty even though the current-drive root |
| 188 | // is just as broad as an explicit C:\\ root. |
| 189 | if strings.Trim(raw, `\/`) == "" { |
| 190 | return true |
| 191 | } |
| 192 | } |
| 193 | abs = filepath.Clean(raw) |
| 194 | if runtime.GOOS == "windows" { |
| 195 | vol := filepath.VolumeName(abs) |
| 196 | if vol == "" { |
| 197 | return false |
| 198 | } |
| 199 | rest := strings.TrimPrefix(abs, vol) |
| 200 | return strings.Trim(rest, `\/`) == "" |
| 201 | } |
| 202 | return abs == string(filepath.Separator) |
| 203 | } |
| 204 | |
| 205 | // IsHomeDir reports whether abs is the user's home directory. |
| 206 | func IsHomeDir(abs, home string) bool { |
| 207 | abs = canonicalDir(abs) |
| 208 | home = canonicalDir(home) |
| 209 | if abs == "" || home == "" { |
| 210 | return false |
| 211 | } |
| 212 | if abs == home { |
| 213 | return true |
| 214 | } |
| 215 | if runtime.GOOS == "windows" || runtime.GOOS == "darwin" { |
| 216 | return strings.EqualFold(abs, home) |
| 217 | } |
| 218 | return false |
| 219 | } |
| 220 | |
| 221 | func canonicalDir(path string) string { |
| 222 | path = strings.TrimSpace(path) |
| 223 | if path == "" { |
| 224 | return "" |
| 225 | } |
| 226 | if resolved, err := ResolveAbsPath(path); err == nil { |
| 227 | return resolved |
| 228 | } |
| 229 | return filepath.Clean(path) |
| 230 | } |
| 231 | |
| 232 | // ProtectedWriteRoots returns the Reasonix state boundary that must stay |
| 233 | // read-only after a broad ancestor grant. Protecting the parent also covers |
| 234 | // state files that do not exist when the sandbox starts. |
| 235 | func ProtectedWriteRoots(stateRoot string) []string { |
| 236 | stateRoot = canonicalDir(stateRoot) |
| 237 | if stateRoot == "" { |
| 238 | return nil |
| 239 | } |
| 240 | return []string{stateRoot} |
| 241 | } |
| 242 | |
| 243 | // IsProtectedWritePath reports whether abs is a Reasonix session store, |
| 244 | // runtime ledger, or security-boundary file. |
| 245 | func IsProtectedWritePath(abs, stateRoot string) bool { |
| 246 | abs = canonicalDir(abs) |
| 247 | stateRoot = canonicalDir(stateRoot) |
| 248 | if abs == "" || stateRoot == "" { |
| 249 | return false |
| 250 | } |
| 251 | if sameWritePath(abs, stateRoot) { |
| 252 | return true |
| 253 | } |
| 254 | if protectedPathWithin(filepath.Join(stateRoot, "sessions"), abs) { |
| 255 | return true |
| 256 | } |
| 257 | relRoot, relPath := stateRoot, abs |
| 258 | if runtime.GOOS == "windows" || runtime.GOOS == "darwin" { |
| 259 | relRoot, relPath = strings.ToLower(relRoot), strings.ToLower(relPath) |
| 260 | } |
| 261 | if rel, err := filepath.Rel(relRoot, relPath); err == nil && rel != "." && !strings.Contains(rel, string(filepath.Separator)) { |
| 262 | if isProtectedStateFile(rel) { |
| 263 | return true |
| 264 | } |
| 265 | } |
| 266 | return protectedPathWithin(filepath.Join(stateRoot, "projects"), abs) |
| 267 | } |
| 268 | |
| 269 | func protectedPathWithin(root, path string) bool { |
| 270 | if runtime.GOOS == "windows" || runtime.GOOS == "darwin" { |
| 271 | root, path = strings.ToLower(root), strings.ToLower(path) |
| 272 | } |
| 273 | return PathWithin(root, path) |
| 274 | } |
| 275 | |
| 276 | func isProtectedStateFile(name string) bool { |
| 277 | lower := strings.ToLower(name) |
| 278 | if strings.HasPrefix(lower, "desktop-") { |
| 279 | return true |
| 280 | } |
| 281 | switch lower { |
| 282 | case "settings.json", "metrics-pending.json", "crash-pending.json": |
| 283 | return true |
| 284 | default: |
| 285 | return false |
| 286 | } |
| 287 | } |
| 288 | |
| 289 | // NormalizeWriteDirs validates and collapses a list of requested write |
| 290 | // directories. broadHome is true when the request includes the user's home. |
| 291 | func NormalizeWriteDirs(raw []string, workDir, home, stateRoot string) (abs, display []string, broadHome bool, err error) { |
| 292 | seen := map[string]bool{} |
| 293 | for _, dir := range raw { |
| 294 | resolved, _, nerr := NormalizeWriteDir(dir, workDir, home) |
| 295 | if nerr != nil { |
| 296 | return nil, nil, false, nerr |
| 297 | } |
| 298 | if verr := ValidateWriteDir(resolved, stateRoot); verr != nil { |
| 299 | return nil, nil, false, verr |
| 300 | } |
| 301 | if seen[resolved] { |
| 302 | continue |
| 303 | } |
| 304 | seen[resolved] = true |
| 305 | abs = append(abs, resolved) |
| 306 | if IsHomeDir(resolved, home) { |
| 307 | broadHome = true |
| 308 | } |
| 309 | } |
| 310 | abs = CollapseWriteRoots(abs) |
| 311 | display = make([]string, 0, len(abs)) |
| 312 | for _, dir := range abs { |
| 313 | display = append(display, DisplayWritePath(dir, home)) |
| 314 | } |
| 315 | if abs == nil { |
| 316 | abs = []string{} |
| 317 | } |
| 318 | return abs, display, broadHome, nil |
| 319 | } |
| 320 | |
| 321 | // ValidateWriteDir rejects filesystem roots and Reasonix-protected paths. |
| 322 | // The user's home directory is allowed; callers should flag it as high risk. |
| 323 | func ValidateWriteDir(abs, stateRoot string) error { |
| 324 | if IsFilesystemRoot(abs) { |
| 325 | return fmt.Errorf("write directory %q is a filesystem root and cannot be granted", abs) |
| 326 | } |
| 327 | if IsProtectedWritePath(abs, stateRoot) { |
| 328 | return fmt.Errorf("write directory %q is a Reasonix session or runtime-state path and cannot be granted", abs) |
| 329 | } |
| 330 | return nil |
| 331 | } |
| 332 | |
| 333 | // EnsureWriteDir creates approved with 0o755 when it does not exist and |
| 334 | // returns the verified identity that callers must grant. It rejects a path |
| 335 | // whose symlink-resolved identity changed after the approval prompt. |
| 336 | func EnsureWriteDir(approved, stateRoot string) (string, error) { |
| 337 | approved = filepath.Clean(strings.TrimSpace(approved)) |
| 338 | if approved == "" || approved == "." { |
| 339 | return "", fmt.Errorf("write directory is empty") |
| 340 | } |
| 341 | if !filepath.IsAbs(approved) { |
| 342 | return "", fmt.Errorf("write directory %q is not absolute", approved) |
| 343 | } |
| 344 | if err := ValidateWriteDir(approved, stateRoot); err != nil { |
| 345 | return "", err |
| 346 | } |
| 347 | resolved, err := ResolveAbsPath(approved) |
| 348 | if err != nil { |
| 349 | return "", fmt.Errorf("resolve approved write directory %q: %w", approved, err) |
| 350 | } |
| 351 | if !sameWritePath(approved, resolved) { |
| 352 | return "", fmt.Errorf("approved write directory %q changed identity to %q", approved, resolved) |
| 353 | } |
| 354 | if err := ValidateWriteDir(resolved, stateRoot); err != nil { |
| 355 | return "", err |
| 356 | } |
| 357 | |
| 358 | info, err := os.Stat(resolved) |
| 359 | switch { |
| 360 | case err == nil: |
| 361 | if !info.IsDir() { |
| 362 | return "", fmt.Errorf("write path %q is not a directory", approved) |
| 363 | } |
| 364 | case os.IsNotExist(err): |
| 365 | if err := os.MkdirAll(approved, 0o755); err != nil { |
| 366 | return "", fmt.Errorf("create write directory %q: %w", approved, err) |
| 367 | } |
| 368 | default: |
| 369 | return "", fmt.Errorf("stat write directory %q: %w", approved, err) |
| 370 | } |
| 371 | |
| 372 | resolved, err = ResolveAbsPath(approved) |
| 373 | if err != nil { |
| 374 | return "", fmt.Errorf("re-resolve write directory %q: %w", approved, err) |
| 375 | } |
| 376 | if !sameWritePath(approved, resolved) { |
| 377 | return "", fmt.Errorf("approved write directory %q changed identity to %q", approved, resolved) |
| 378 | } |
| 379 | if err := ValidateWriteDir(resolved, stateRoot); err != nil { |
| 380 | return "", err |
| 381 | } |
| 382 | info, err = os.Stat(resolved) |
| 383 | if err != nil { |
| 384 | return "", fmt.Errorf("stat created write directory %q: %w", approved, err) |
| 385 | } |
| 386 | if !info.IsDir() { |
| 387 | return "", fmt.Errorf("created write path %q is not a directory", approved) |
| 388 | } |
| 389 | return resolved, nil |
| 390 | } |
| 391 | |
| 392 | func sameWritePath(left, right string) bool { |
| 393 | left = filepath.Clean(left) |
| 394 | right = filepath.Clean(right) |
| 395 | // Approval identity must remain exact: macOS and Windows can host |
| 396 | // case-sensitive paths. A casing change must re-prompt, not reuse a grant. |
| 397 | return left == right |
| 398 | } |
| 399 |