app_bind_policy.go RawHistoryFolder

1package domain
2
3import (
4	"fmt"
5	"os"
6	"path/filepath"
7	"strings"
8)
9
10// AppBindPolicy is one named administrative bind policy. The installation
11// declares policies; app manifests reference them by name. Source is the
12// only host location a bind governed by this policy may come from. Root,
13// when set, is the administrative boundary the resolved source must remain
14// under. AllowedApps and AllowedServices are exact, non-empty allowlists.
15type AppBindPolicy struct {
16	Name            string
17	Source          string
18	ReadOnly        bool
19	AllowedApps     []string
20	AllowedServices []string
21	Root            string
22}
23
24// ResolvedAppBind is a bind whose source has been resolved and verified for
25// the runtime to mount. It carries no logging, DTO, or transport concerns.
26type ResolvedAppBind struct {
27	Name        string
28	Source      string
29	Destination string
30	ReadOnly    bool
31}
32
33// ValidateBindPolicyName checks a stable policy name (service charset plus -- ban).
34func ValidateBindPolicyName(name string) error {
35	if !serviceNamePattern.MatchString(name) {
36		return fmt.Errorf("%w: policy name %q must match [a-z0-9_.-], max 63", ErrBindPolicy, name)
37	}
38	if strings.Contains(name, "--") {
39		return fmt.Errorf("%w: policy name %q must not contain -- (reserved separator)", ErrBindPolicy, name)
40	}
41	return nil
42}
43
44// Validate checks the policy shape without touching the filesystem.
45func (p AppBindPolicy) Validate() error {
46	if err := ValidateBindPolicyName(p.Name); err != nil {
47		return err
48	}
49	if err := validatePolicyPath("source", p.Source); err != nil {
50		return fmt.Errorf("%w: policy %q: %v", ErrBindPolicy, p.Name, err)
51	}
52	if p.Root != "" {
53		if err := validatePolicyPath("root", p.Root); err != nil {
54			return fmt.Errorf("%w: policy %q: %v", ErrBindPolicy, p.Name, err)
55		}
56	}
57	if err := validateAllowlist("app", p.AllowedApps, ValidateAppName); err != nil {
58		return fmt.Errorf("%w: policy %q: %v", ErrBindPolicy, p.Name, err)
59	}
60	if err := validateAllowlist("service", p.AllowedServices, ValidateServiceName); err != nil {
61		return fmt.Errorf("%w: policy %q: %v", ErrBindPolicy, p.Name, err)
62	}
63	return nil
64}
65
66// validatePolicyPath requires a non-empty, absolute, normalized host path.
67func validatePolicyPath(kind, value string) error {
68	if value == "" {
69		return fmt.Errorf("%s must not be empty", kind)
70	}
71	if !filepath.IsAbs(value) {
72		return fmt.Errorf("%s %q must be absolute", kind, value)
73	}
74	if filepath.Clean(value) != value {
75		return fmt.Errorf("%s %q must be a normalized clean path", kind, value)
76	}
77	if kind == "source" && value == string(filepath.Separator) {
78		return fmt.Errorf("source must not be the filesystem root")
79	}
80	return nil
81}
82
83// validateAllowlist requires a non-empty exact list of valid, unique names.
84func validateAllowlist(kind string, names []string, validate func(string) error) error {
85	if len(names) == 0 {
86		return fmt.Errorf("allowed %ss must not be empty", kind)
87	}
88	seen := map[string]struct{}{}
89	for _, name := range names {
90		if err := validate(name); err != nil {
91			return fmt.Errorf("allowed %s %q: %w", kind, name, err)
92		}
93		if _, ok := seen[name]; ok {
94			return fmt.Errorf("allowed %s %q is duplicated", kind, name)
95		}
96		seen[name] = struct{}{}
97	}
98	return nil
99}
100
101// ResolveAppBind resolves one AppBind under this policy for the given app and
102// service. It validates the policy and bind shapes, enforces the exact
103// app/service allowlists, resolves the source through symlinks, verifies it is
104// a regular file or directory that remains under the configured root, and
105// never weakens read-only intent (policy read-only and bind read-only both
106// force the resolved bind read-only). The result is runtime-ready: callers
107// mount ResolvedAppBind.Source at Destination.
108func (p AppBindPolicy) ResolveAppBind(app, service string, bind AppBind) (ResolvedAppBind, error) {
109	if err := p.Validate(); err != nil {
110		return ResolvedAppBind{}, err
111	}
112	if !allowlistContains(p.AllowedApps, app) {
113		return ResolvedAppBind{}, fmt.Errorf("%w: policy %q does not allow app %q", ErrBindPolicy, p.Name, app)
114	}
115	if !allowlistContains(p.AllowedServices, service) {
116		return ResolvedAppBind{}, fmt.Errorf("%w: policy %q does not allow service %q", ErrBindPolicy, p.Name, service)
117	}
118	if err := ValidateBindName(bind.Name); err != nil {
119		return ResolvedAppBind{}, err
120	}
121	if err := ValidateBindDestination(bind.Path); err != nil {
122		return ResolvedAppBind{}, err
123	}
124	source, err := p.resolveSource()
125	if err != nil {
126		return ResolvedAppBind{}, err
127	}
128	return ResolvedAppBind{
129		Name:        bind.Name,
130		Source:      source,
131		Destination: bind.Path,
132		ReadOnly:    p.ReadOnly || bind.ReadOnly,
133	}, nil
134}
135
136// allowlistContains reports exact membership.
137func allowlistContains(names []string, candidate string) bool {
138	for _, name := range names {
139		if name == candidate {
140			return true
141		}
142	}
143	return false
144}
145
146// resolveSource resolves and verifies the policy source against the filesystem.
147func (p AppBindPolicy) resolveSource() (string, error) {
148	resolvedSource, err := filepath.EvalSymlinks(p.Source)
149	if err != nil {
150		return "", fmt.Errorf("%w: policy %q source %q: %v", ErrBindPolicy, p.Name, p.Source, err)
151	}
152	info, err := os.Lstat(resolvedSource)
153	if err != nil {
154		return "", fmt.Errorf("%w: policy %q source %q: %v", ErrBindPolicy, p.Name, p.Source, err)
155	}
156	if !bindSourceTypeAllowed(info.Mode()) {
157		return "", fmt.Errorf("%w: policy %q source %q must be a regular file or directory", ErrBindPolicy, p.Name, p.Source)
158	}
159	if p.Root == "" {
160		return resolvedSource, nil
161	}
162	resolvedRoot, err := filepath.EvalSymlinks(p.Root)
163	if err != nil {
164		return "", fmt.Errorf("%w: policy %q root %q: %v", ErrBindPolicy, p.Name, p.Root, err)
165	}
166	if !resolvedUnderRoot(resolvedRoot, resolvedSource) {
167		return "", fmt.Errorf("%w: policy %q source %q resolves outside root %q", ErrBindPolicy, p.Name, p.Source, p.Root)
168	}
169	return resolvedSource, nil
170}
171
172// bindSourceTypeAllowed accepts only regular files and directories. Devices,
173// sockets, FIFOs, symlinks, and other special files are refused.
174func bindSourceTypeAllowed(mode os.FileMode) bool {
175	return mode.IsRegular() || mode.IsDir()
176}
177
178// resolvedUnderRoot reports whether target is root itself or a descendant of
179// root. Both paths must already be resolved.
180func resolvedUnderRoot(root, target string) bool {
181	rel, err := filepath.Rel(root, target)
182	if err != nil {
183		return false
184	}
185	if rel == "." {
186		return true
187	}
188	return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
189}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close