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}