| 1 | package config |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "slices" |
| 11 | "strings" |
| 12 | "time" |
| 13 | |
| 14 | "reasonix/internal/filelock" |
| 15 | "reasonix/internal/fileutil" |
| 16 | "reasonix/internal/permission" |
| 17 | "reasonix/internal/workspaceid" |
| 18 | ) |
| 19 | |
| 20 | // ProjectGrant is what the user granted one workspace folder: allow rules |
| 21 | // answered "always" and extra writable directories. It lives under the user's |
| 22 | // home, keyed by the folder's own path: a grant has no digest to bind it, so it |
| 23 | // must not follow a repository identity a checkout's .git file could claim. |
| 24 | type ProjectGrant struct { |
| 25 | Allow []string `json:"allow,omitempty"` |
| 26 | AllowWrite []string `json:"allow_write,omitempty"` |
| 27 | } |
| 28 | |
| 29 | type projectGrantFile struct { |
| 30 | Version int `json:"version"` |
| 31 | Workspaces map[string]ProjectGrant `json:"workspaces"` |
| 32 | } |
| 33 | |
| 34 | const ( |
| 35 | projectGrantsFilename = "project-grants.json" |
| 36 | projectGrantsLockFile = ".project-grants.lock" |
| 37 | projectGrantsVersion = 1 |
| 38 | ) |
| 39 | |
| 40 | // ErrProjectGrantsUnavailable is a record that could not be read or written. |
| 41 | var ErrProjectGrantsUnavailable = errors.New("workspace grants unavailable") |
| 42 | |
| 43 | // ProjectGrantStore persists grants in <Reasonix home>/project-grants.json. |
| 44 | type ProjectGrantStore struct { |
| 45 | path string |
| 46 | } |
| 47 | |
| 48 | // NewProjectGrantStore opens the record under home. |
| 49 | func NewProjectGrantStore(home string) *ProjectGrantStore { |
| 50 | if strings.TrimSpace(home) == "" { |
| 51 | return &ProjectGrantStore{} |
| 52 | } |
| 53 | return &ProjectGrantStore{path: filepath.Join(home, projectGrantsFilename)} |
| 54 | } |
| 55 | |
| 56 | // Path is the file the grants are written to. |
| 57 | func (s *ProjectGrantStore) Path() string { return s.path } |
| 58 | |
| 59 | // Grant returns what root was granted. |
| 60 | func (s *ProjectGrantStore) Grant(root string) (ProjectGrant, error) { |
| 61 | file, err := s.load() |
| 62 | if err != nil { |
| 63 | return ProjectGrant{}, err |
| 64 | } |
| 65 | return file.Workspaces[workspaceid.PathFingerprint(root)], nil |
| 66 | } |
| 67 | |
| 68 | // Update replaces root's grant with what edit returns, under a cross-process lock. |
| 69 | func (s *ProjectGrantStore) Update(root string, edit func(ProjectGrant) (ProjectGrant, error)) error { |
| 70 | ws := workspaceid.PathFingerprint(root) |
| 71 | if s.path == "" || ws == "" { |
| 72 | return fmt.Errorf("%w: no Reasonix home or workspace", ErrProjectGrantsUnavailable) |
| 73 | } |
| 74 | if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil { |
| 75 | return fmt.Errorf("%w: %w", ErrProjectGrantsUnavailable, err) |
| 76 | } |
| 77 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) |
| 78 | defer cancel() |
| 79 | unlock, err := filelock.Acquire(ctx, filepath.Join(filepath.Dir(s.path), projectGrantsLockFile)) |
| 80 | if err != nil { |
| 81 | return fmt.Errorf("%w: %w", ErrProjectGrantsUnavailable, err) |
| 82 | } |
| 83 | defer unlock() |
| 84 | file, err := s.load() |
| 85 | if err != nil { |
| 86 | return err |
| 87 | } |
| 88 | current := file.Workspaces[ws] |
| 89 | next, err := edit(ProjectGrant{Allow: slices.Clone(current.Allow), AllowWrite: slices.Clone(current.AllowWrite)}) |
| 90 | if err != nil { |
| 91 | return err |
| 92 | } |
| 93 | for _, rule := range next.Allow { |
| 94 | if _, ok := permission.ParseRule(rule); !ok { |
| 95 | return fmt.Errorf("invalid permission rule %q (want \"ToolName\" or \"ToolName(glob)\")", rule) |
| 96 | } |
| 97 | } |
| 98 | file.Workspaces[ws] = next |
| 99 | file.Version = projectGrantsVersion |
| 100 | data, err := json.MarshalIndent(file, "", " ") |
| 101 | if err != nil { |
| 102 | return fmt.Errorf("%w: %w", ErrProjectGrantsUnavailable, err) |
| 103 | } |
| 104 | if err := fileutil.AtomicWriteFile(s.path, append(data, '\n'), 0o600); err != nil { |
| 105 | return fmt.Errorf("%w: %w", ErrProjectGrantsUnavailable, err) |
| 106 | } |
| 107 | return nil |
| 108 | } |
| 109 | |
| 110 | func (s *ProjectGrantStore) load() (projectGrantFile, error) { |
| 111 | file := projectGrantFile{Version: projectGrantsVersion, Workspaces: map[string]ProjectGrant{}} |
| 112 | if s.path == "" { |
| 113 | return file, nil |
| 114 | } |
| 115 | data, err := os.ReadFile(s.path) |
| 116 | if os.IsNotExist(err) { |
| 117 | return file, nil |
| 118 | } |
| 119 | if err != nil { |
| 120 | return file, fmt.Errorf("%w: %w", ErrProjectGrantsUnavailable, err) |
| 121 | } |
| 122 | if err := json.Unmarshal(data, &file); err != nil { |
| 123 | return file, fmt.Errorf("%w: %s: %w", ErrProjectGrantsUnavailable, s.path, err) |
| 124 | } |
| 125 | if file.Workspaces == nil { |
| 126 | file.Workspaces = map[string]ProjectGrant{} |
| 127 | } |
| 128 | return file, nil |
| 129 | } |
| 130 |