1package daemon
2
3import (
4 "path/filepath"
5 "slices"
6 "strings"
7 "unicode/utf8"
8
9 "github.com/bnema/vev/internal/ports"
10 "github.com/bnema/vev/internal/protocol"
11)
12
13// admissionCwdMaxBytes bounds the client-supplied working directory a local
14// admitted attachment may carry. It mirrors the Hello wire bound (uint16
15// length) so an admission can never authorize a value the wire could not carry.
16const admissionCwdMaxBytes = 65535
17
18// sessionHelloAdmission reads the provisioned admission metadata the accepting
19// side stamped onto an admitted connection, through the optional structural
20// provider seam. A connection built without that seam - a direct package
21// caller, a sessionwire constructor without admission, or a raw test transport - reports
22// present=false, and the daemon applies the standard Hello validation.
23// The provider hands back a defensive copy, so the admission the daemon
24// validates can never be mutated through the connection.
25func sessionHelloAdmission(tr ports.ServerConnection) (ports.SessionAdmission, bool) {
26 provider, ok := tr.(ports.SessionAdmissionProvider)
27 if !ok || provider == nil {
28 return ports.SessionAdmission{}, false
29 }
30 admission, ok := provider.SessionAdmission()
31 if !ok {
32 return ports.SessionAdmission{}, false
33 }
34 return admission, true
35}
36
37// admissionError is the typed refusal for a Hello that contradicts, or is not
38// covered by, the provisioned admission it arrived under. It is a client
39// refusal rather than an internal failure: the caller can surface it and retry
40// under the accepted authority.
41func admissionError(text string) error {
42 return &protoErr{protocol.ErrNoSuchTarget, text}
43}
44
45// validateSessionHelloAdmission enforces the closed admission contract for one
46// Hello that arrived on an admitted connection. The admission is the accepting
47// side's own authority, so every field the peer declared in Hello is checked
48// against it rather than trusted:
49// - the connection must be an attachment stream, so a control or observation
50// stream can never become an attachment by sending a Hello;
51// - a local admission carries a client-owned policy and an optional, bounded,
52// absolute working directory, while a remote admission carries a
53// daemon-owned policy and no client environment or working directory at all;
54// - the Hello's locality, environment policy, and ordered environment must
55// equal the admitted values exactly;
56// - the closed admission variant maps to exactly one intent and credential
57// shape, so a create can never smuggle a resume token or a target, and an
58// exact attach can never create.
59func validateSessionHelloAdmission(h protocol.Hello, admission ports.SessionAdmission) error {
60 if err := admission.Validate(); err != nil {
61 return admissionError("invalid session admission")
62 }
63 if admission.Purpose != ports.BrokerStreamAttachment {
64 return admissionError("stream admission is not an attachment")
65 }
66 switch admission.Origin {
67 case ports.SessionOriginLocal:
68 if admission.Policy.EnvironmentPolicy != protocol.EnvironmentPolicyClientOwned {
69 return admissionError("local admission requires a client-owned environment policy")
70 }
71 if err := validateLocalAdmissionCwd(h.Cwd); err != nil {
72 return err
73 }
74 case ports.SessionOriginRemote:
75 if admission.Policy.EnvironmentPolicy != protocol.EnvironmentPolicyDaemonOwned {
76 return admissionError("remote admission requires a daemon-owned environment policy")
77 }
78 if len(h.Env) != 0 || h.Cwd != "" {
79 return admissionError("remote admission carries a client environment or working directory")
80 }
81 default:
82 return admissionError("unknown session admission origin")
83 }
84 if h.Remote != (admission.Origin == ports.SessionOriginRemote) {
85 return admissionError("hello locality contradicts the admitted origin")
86 }
87 if h.EnvironmentPolicy != admission.Policy.EnvironmentPolicy {
88 return admissionError("hello environment policy contradicts the admitted policy")
89 }
90 if !slices.Equal(h.Env, admission.Env) {
91 return admissionError("hello environment contradicts the admitted environment")
92 }
93 switch admission.Admission {
94 case ports.BrokerAdmissionCreateEphemeral:
95 if h.Intent != protocol.IntentEphemeral {
96 return admissionError("ephemeral admission requires an ephemeral hello")
97 }
98 if h.Name != "" || h.ExactTarget != nil || h.SessionTarget != nil || h.ResumeToken != 0 {
99 return admissionError("ephemeral admission forbids a name, target, or resume token")
100 }
101 case ports.BrokerAdmissionCreateNamed:
102 if h.Intent != protocol.IntentNew {
103 return admissionError("named admission requires a new-session hello")
104 }
105 if h.Name != admission.Name {
106 return admissionError("hello name contradicts the admitted session name")
107 }
108 if h.ExactTarget != nil || h.SessionTarget != nil || h.ResumeToken != 0 {
109 return admissionError("named admission forbids a target or resume token")
110 }
111 case ports.BrokerAdmissionAttachNamed:
112 if h.Intent != protocol.IntentAttach {
113 return admissionError("named attach admission requires an attach hello")
114 }
115 if h.Name != admission.Name {
116 return admissionError("hello name contradicts the admitted session name")
117 }
118 if h.ExactTarget != nil || h.SessionTarget != nil || h.ResumeToken != 0 {
119 return admissionError("named attach admission forbids a target or resume token")
120 }
121 case ports.BrokerAdmissionExact:
122 if h.ExactTarget == nil || *h.ExactTarget != admission.Target {
123 return admissionError("hello exact target contradicts the admitted target")
124 }
125 if h.Name != admission.Target.SessionName {
126 return admissionError("hello name contradicts the admitted target")
127 }
128 if h.ResumeToken == 0 {
129 if h.Intent != protocol.IntentAttach {
130 return admissionError("fresh exact admission requires an attach hello")
131 }
132 } else if h.Intent != protocol.IntentResume {
133 return admissionError("resume exact admission requires a resume hello")
134 }
135 if h.SessionTarget != nil && (h.SessionTarget.LifecycleID != admission.Target.LifecycleID ||
136 h.SessionTarget.SessionName != admission.Target.SessionName) {
137 return admissionError("session target contradicts the admitted target")
138 }
139 default:
140 return admissionError("unknown attachment admission")
141 }
142 return nil
143}
144
145// validateLocalAdmissionCwd bounds the client-supplied working directory of a
146// local admitted attachment. It stays a client data field rather than a proof:
147// an absent value falls back to the daemon's existing home resolution, and a
148// present value must be a bounded, valid, absolute path so it can never name a
149// relative or malformed location.
150func validateLocalAdmissionCwd(cwd string) error {
151 if cwd == "" {
152 return nil
153 }
154 if len(cwd) > admissionCwdMaxBytes {
155 return admissionError("client working directory is too long")
156 }
157 if !utf8.ValidString(cwd) || strings.ContainsRune(cwd, 0) {
158 return admissionError("client working directory is invalid")
159 }
160 if !filepath.IsAbs(cwd) {
161 return admissionError("client working directory is not absolute")
162 }
163 return nil
164}
165
166// validateResumeAdmittedTarget refuses an admitted exact resume whose target
167// does not name the exact session lifecycle the resume token owns, before any
168// claim, transport replacement, or ownership mutation. An unknown or
169// still-parking credential reports no owned session and returns nil, so the
170// existing fail-closed resume path keeps its own semantics for that race.
171func (d *Daemon) validateResumeAdmittedTarget(token uint64, target protocol.ExactSessionTarget) error {
172 if token == 0 {
173 return nil
174 }
175 sess := d.resumeTokenOwnedSession(token)
176 if sess == nil {
177 return nil
178 }
179 sess.mu.Lock()
180 name, incarnation := sess.name, sess.incarnation
181 sess.mu.Unlock()
182 if name != target.SessionName || incarnation != target.LifecycleID {
183 return admissionError("resume target contradicts the admitted session")
184 }
185 return nil
186}
187
188// resumeTokenOwnedSession resolves the exact session a resume credential
189// currently owns: the parked entry, an in-flight parking entry, or the live
190// attachment holding the token. It takes d.mu then a session lock, matching the
191// daemon's existing lock order, and returns nil when no session is owned.
192func (d *Daemon) resumeTokenOwnedSession(token uint64) *session {
193 d.mu.Lock()
194 defer d.mu.Unlock()
195 if sess := d.resume.sessionFor(token); sess != nil {
196 return sess
197 }
198 for _, sess := range d.sessions {
199 if sess == nil {
200 continue
201 }
202 sess.mu.Lock()
203 for candidate := range sess.attachments {
204 if candidate.attachmentActivity() == attachmentActive && candidate.resumeCapable && candidate.resumeToken == token {
205 sess.mu.Unlock()
206 return sess
207 }
208 }
209 sess.mu.Unlock()
210 }
211 return nil
212}