返回 DeepSeek-Reasonix
project_grants.go
根目录 / internal / config / project_grants.go
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
130 lines GO