main ece7682

config.go RawHistoryFolder

1// Package brokerconfig owns the strict, isolated offline broker sandbox
2// configuration.
3//
4// It is deliberately not production composition: nothing in internal/app or
5// main consults it for ordinary daemon operation. It resolves one caller-
6// supplied offline root into sandbox-owned runtime, state, and log paths,
7// validates that the root is a private, absolute, cleaned, symlink-free
8// directory that does not overlap production runtime or state, and strictly
9// parses the bounded config.json marker that provisions immutable endpoint
10// registrations, each carrying a stable daemon identity, an exact connection
11// policy, and one bounded route (a private Unix mux socket, an SSH stdio mux
12// helper, or an SSH-authenticated QUIC bootstrap).
13//
14// The parsed configuration is immutable and is the only source of endpoint
15// addresses, identities, and policies. A client request can name a configured
16// endpoint and must carry that endpoint's exact registration and compatible
17// policy; it can never supply an address, identity, secret, command, or policy
18// of its own, and an unknown or stale request is refused without dialing. A
19// local request carries no endpoint or registration and is instead fenced
20// against the optional broker-owned local binding (identity, policy, and Unix
21// daemonmux route), so it too can never supply authority of its own. The
22// route's pool address is an opaque digest, so no route detail is exposed to the
23// pooling layer.
24//
25// Because the pool keys one physical transport by the exact authenticated
26// identity plus policy pair and ignores that opaque address, two registrations
27// that share the pair must agree on the one route the pool dials: distinct
28// endpoints may alias an identical route, but a second route for the same pair
29// is refused rather than silently stranded.
30package brokerconfig
31
32import (
33	"bytes"
34	"encoding/hex"
35	"encoding/json"
36	"errors"
37	"fmt"
38	"io"
39	"io/fs"
40	"os"
41	"path/filepath"
42	"strings"
43	"syscall"
44	"time"
45	"unicode/utf8"
46
47	"github.com/bnema/vev/internal/domain"
48	"github.com/bnema/vev/internal/ports"
49	"github.com/bnema/vev/internal/protocol"
50)
51
52// Marker is the exact config.json marker that identifies a broker offline
53// sandbox configuration. A file without it is never treated as broker config,
54// so an accidental --offline-root pointing at unrelated JSON fails closed.
55const Marker = "vev.broker.offline/v1"
56
57// ConfigFileName is the fixed config file name inside one offline root.
58const ConfigFileName = "config.json"
59
60// MaxConfigBytes bounds the config file before any decoding.
61const MaxConfigBytes = 1 << 20
62
63// MaxIdleGrace bounds a provisioned idle grace so an accidental enormous value
64// cannot make an offline broker effectively immortal.
65const MaxIdleGrace = 24 * time.Hour
66
67// Warm transport retention (the broker-owned replacement for the removed
68// client remote.attachment-cache keys). DefaultWarmTransports matches that
69// cache's default capacity; MaxWarmTransports matches the pool's physical
70// bound. The default idle timeout is off (no age bound), also matching it;
71// a provisioned timeout is bounded by MaxIdleGrace.
72const (
73	DefaultWarmTransports = 8
74	MaxWarmTransports     = 64
75)
76
77// Sandbox directory names derived from one offline root. SpawnDirName is the
78// private directory under Runtime that holds the descriptor-backed
79// spawn-election lock, deliberately separate from the broker lifetime lock in
80// Runtime itself so electing a spawner can never collide with lifetime
81// ownership.
82const (
83	RuntimeDirName = "runtime"
84	StateDirName   = "state"
85	LogDirName     = "log"
86	SpawnDirName   = "spawn"
87)
88
89var (
90	// errUnknownEndpoint reports a request for an endpoint the immutable
91	// configuration never provisioned.
92	errUnknownEndpoint = errors.New("brokerconfig: unknown broker endpoint")
93	// errStaleRegistration reports a request whose registration no longer
94	// matches the provisioned immutable registration.
95	errStaleRegistration = errors.New("brokerconfig: stale broker registration")
96	// errNoLocalRoute reports a local request when the configuration provisions
97	// no broker-owned local binding, so the broker owns no local route.
98	errNoLocalRoute = errors.New("brokerconfig: offline broker has no local route")
99	// errLocalPolicyConflict reports a local request whose policy is not exactly
100	// compatible with the provisioned local policy. The provisioned policy stays
101	// authoritative: the request can never widen, narrow, or replace it.
102	errLocalPolicyConflict = errors.New("brokerconfig: local request policy conflicts with the provisioned local policy")
103)
104
105// Layout names the sandbox-owned paths derived from one offline root. Every
106// path is beneath Root; no production runtime or state path is ever derived. It
107// also carries the reserved production directories it was validated against, so
108// Load can fence every provisioned route against them too.
109type Layout struct {
110	Root    string
111	Runtime string
112	State   string
113	Log     string
114	// Spawn is the private spawn-election directory beneath Runtime.
115	Spawn string
116
117	// reserved holds the cleaned absolute production runtime and state paths
118	// supplied to ResolveLayout. Load refuses a provisioned route that overlaps
119	// them; it is unexported so only ResolveLayout can set it.
120	reserved []string
121}
122
123// VerifyCreated re-walks every sandbox-owned path and refuses any existing
124// component that is a symlink.
125//
126// ResolveLayout validates the root textually and stops at the first component
127// that does not exist yet, so the composing caller calls this once
128// pkg/safedir.EnsurePrivate has created the runtime, state, and log
129// directories. It closes the gap between that validation and the first use of
130// the created paths: a component swapped for a symlink in the window fails
131// closed instead of being followed.
132func (l Layout) VerifyCreated() error {
133	for _, path := range []string{l.Root, l.Runtime, l.State, l.Log, l.Spawn} {
134		if err := rejectSymlinkedComponents(path); err != nil {
135			return err
136		}
137	}
138	return nil
139}
140
141// rejectSymlinkedComponents refuses a path any of whose existing components is
142// a symlink not owned by root. A component that does not exist yet ends the
143// walk: the caller creates the remaining private directories itself.
144//
145// Root-owned symlinks are system layout, such as macOS /var -> private/var and
146// /tmp -> private/tmp; no other user can plant or retarget them.
147func rejectSymlinkedComponents(abs string) error {
148	volume := filepath.VolumeName(abs)
149	rest := strings.TrimPrefix(abs, volume)
150	rest = strings.TrimPrefix(rest, string(filepath.Separator))
151	current := volume + string(filepath.Separator)
152	if rest == "" {
153		return nil
154	}
155	for _, part := range strings.Split(rest, string(filepath.Separator)) {
156		if part == "" {
157			continue
158		}
159		current = filepath.Join(current, part)
160		info, err := os.Lstat(current)
161		if err != nil {
162			if errors.Is(err, fs.ErrNotExist) {
163				return nil
164			}
165			return fmt.Errorf("brokerconfig: inspect %q: %w", current, err)
166		}
167		if info.Mode()&os.ModeSymlink != 0 && !ownedByRoot(info) {
168			return fmt.Errorf("brokerconfig: %q is a symlink", current)
169		}
170	}
171	return nil
172}
173
174func ownedByRoot(info fs.FileInfo) bool {
175	stat, ok := info.Sys().(*syscall.Stat_t)
176	return ok && stat.Uid == 0
177}
178
179// overlaps reports whether two absolute cleaned paths are equal or one contains
180// the other.
181func overlaps(a, b string) bool {
182	if a == b {
183		return true
184	}
185	sep := string(filepath.Separator)
186	return strings.HasPrefix(a, b+sep) || strings.HasPrefix(b, a+sep)
187}
188
189// Registration is one immutable, provisioned endpoint: the exact daemon
190// registration a request must carry, the stable authenticated daemon identity,
191// the exact connection policy, and the bounded route the connector dials.
192type Registration struct {
193	Registration domain.RemoteRegistration
194	Identity     ports.BrokerDaemonIdentity
195	Policy       ports.BrokerPolicy
196	Route        Route
197}
198
199// LocalBinding is the broker-owned binding for the broker's own machine
200// daemon. Unlike a remote Registration it carries no
201// registration: the local daemon is not a configured host, so a local stream
202// request is fenced only against this binding's identity, policy, and route.
203// The identity and policy are authoritative here and are the exact source of
204// the resolved endpoint and of the published local observation's configured
205// authority, so a local request can never select an identity or policy of its
206// own.
207type LocalBinding struct {
208	// Identity is the stable authenticated daemon identity the local
209	// daemonmux carriage must present during the physical preamble.
210	Identity ports.BrokerDaemonIdentity
211	// DisplayOrigin is the presentation hint published for the local
212	// observation. It is sanitized at load time and is never routing authority.
213	DisplayOrigin string
214	// Policy is the exact connection policy the local carriage negotiates.
215	Policy ports.BrokerPolicy
216	// Route is the broker-owned local daemonmux carriage. It is always a Unix
217	// route this process dials directly (Route.IsLocal() is true). The broker
218	// dials it itself, so it is never a remote-side mux helper route
219	// (Config.LocalMuxRoute).
220	Route Route
221}
222
223// pooledIdentity is the exact (authenticated identity, policy) pair the broker
224// pool keys one physical transport by the exact identity and policy pair. The pool deliberately ignores the opaque
225// route address, so two registrations that share a pair share one pooled
226// transport and must therefore agree on the single route that transport dials.
227type pooledIdentity struct {
228	identity ports.BrokerDaemonIdentity
229	policy   ports.BrokerPolicy
230}
231
232// Config is the validated, immutable offline broker configuration.
233type Config struct {
234	byEndpoint map[string]Registration
235	byAddress  map[string]Route
236	unixRoutes []Route
237	order      []string
238	// local is the optional broker-owned local binding. It is nil when the
239	// configuration provisions no local route, in which case a local request is
240	// refused and no local observation is produced.
241	local *LocalBinding
242	// idleGrace is the provisioned effective idle grace; hasIdleGrace records
243	// whether the file provisioned one, so a caller can distinguish "no
244	// provisioning, use the built-in default" from "provisioned".
245	idleGrace    time.Duration
246	hasIdleGrace bool
247	// warmTransports and warmIdleTimeout bound warm physical transport reuse;
248	// see WarmTransports and WarmIdleTimeout.
249	warmTransports  int
250	warmIdleTimeout time.Duration
251}
252
253// LoadPath parses the broker configuration at an explicit application-owned
254// path while retaining the route and reserved-path validation from layout.
255func LoadPath(layout Layout, path string) (*Config, error) {
256	return loadPath(layout, path, nil)
257}
258
259// LoadProduction reads broker.json while deriving local daemon authority from
260// daemon-owned state. The production file must not carry a local identity.
261func LoadProduction(layout Layout, path string, identity ports.BrokerDaemonIdentity, policy ports.BrokerPolicy, socketPath string) (*Config, error) {
262	// An absent identity is valid before the local daemon's first start. A
263	// present identity remains strict and becomes the expected handshake fence.
264	if identity != "" {
265		if err := identity.Validate(); err != nil {
266			return nil, err
267		}
268	}
269	if err := policy.Validate(); err != nil {
270		return nil, err
271	}
272	route, err := newUnixRoute(socketPath)
273	if err != nil {
274		return nil, err
275	}
276	local := &LocalBinding{Identity: identity, DisplayOrigin: LocalDisplayOrigin, Policy: policy, Route: route}
277	return loadPath(layout, path, local)
278}
279
280func loadPath(layout Layout, path string, authoritativeLocal *LocalBinding) (*Config, error) {
281	raw, err := readConfig(path)
282	if err != nil {
283		return nil, err
284	}
285	var document configDocument
286	if err := decodeStrict(raw, &document); err != nil {
287		return nil, fmt.Errorf("brokerconfig: %s: %w", ConfigFileName, err)
288	}
289	if document.Marker != Marker {
290		return nil, fmt.Errorf("brokerconfig: %s: missing or invalid marker", ConfigFileName)
291	}
292	config := &Config{
293		byEndpoint: make(map[string]Registration, len(document.Registrations)),
294		byAddress:  make(map[string]Route, len(document.Registrations)),
295		order:      make([]string, 0, len(document.Registrations)),
296	}
297	if document.IdleGrace != "" {
298		grace, err := time.ParseDuration(document.IdleGrace)
299		if err != nil {
300			return nil, fmt.Errorf("brokerconfig: %s: idleGrace %q is not a duration", ConfigFileName, document.IdleGrace)
301		}
302		if grace <= 0 || grace > MaxIdleGrace {
303			return nil, fmt.Errorf("brokerconfig: %s: idleGrace must be positive and at most %s", ConfigFileName, MaxIdleGrace)
304		}
305		config.idleGrace, config.hasIdleGrace = grace, true
306	}
307	if config.warmTransports, config.warmIdleTimeout, err = document.warm(); err != nil {
308		return nil, err
309	}
310	pooledRoutes := make(map[pooledIdentity]string, len(document.Registrations)+1)
311	for i, entry := range document.Registrations {
312		registration, err := entry.validate()
313		if err != nil {
314			return nil, fmt.Errorf("brokerconfig: registration %d: %w", i, err)
315		}
316		if registration.Route.IsLocal() {
317			if err := rejectRouteOverlap(registration.Route.Path(), layout.reserved); err != nil {
318				return nil, fmt.Errorf("brokerconfig: registration %d: %w", i, err)
319			}
320		}
321		if _, duplicate := config.byEndpoint[registration.Registration.Endpoint]; duplicate {
322			return nil, fmt.Errorf("brokerconfig: registration %d: duplicate endpoint %q", i, registration.Registration.Endpoint)
323		}
324		// The pool keys one physical transport by (identity, policy) and ignores
325		// the route address, so a second address for the same pair would be
326		// dialed for neither or both depending on scheduling. Refuse it and allow
327		// an alias only when the routes are identical.
328		key := pooledIdentity{identity: registration.Identity, policy: registration.Policy}
329		address := registration.Route.Address()
330		if existing, present := pooledRoutes[key]; present && existing != address {
331			return nil, fmt.Errorf("brokerconfig: registration %d: identity %q and policy already provisioned on a different route", i, registration.Identity)
332		}
333		pooledRoutes[key] = address
334		config.byEndpoint[registration.Registration.Endpoint] = registration
335		config.byAddress[address] = registration.Route
336		if registration.Route.IsLocal() {
337			config.unixRoutes = appendUnixRouteOnce(config.unixRoutes, registration.Route)
338		}
339		config.order = append(config.order, registration.Registration.Endpoint)
340	}
341	if authoritativeLocal != nil && document.Local != nil {
342		return nil, fmt.Errorf("brokerconfig: %s: production local authority must not be configured", ConfigFileName)
343	}
344	local := authoritativeLocal
345	if local == nil {
346		local, err = parseLocal(document.Local)
347		if err != nil {
348			return nil, fmt.Errorf("brokerconfig: %s: local: %w", ConfigFileName, err)
349		}
350	}
351	if local != nil {
352		if err := rejectRouteOverlap(local.Route.Path(), layout.reserved); err != nil {
353			return nil, fmt.Errorf("brokerconfig: %s: local: %w", ConfigFileName, err)
354		}
355		// The pool keys one physical transport by (identity, policy) and ignores
356		// the route address, so a local binding that shares a remote (or another
357		// local) pair must agree on the one route that transport dials. Refuse a
358		// second route for the same pair, exactly as the registration loop does.
359		key := pooledIdentity{identity: local.Identity, policy: local.Policy}
360		address := local.Route.Address()
361		if existing, present := pooledRoutes[key]; present && existing != address {
362			return nil, fmt.Errorf("brokerconfig: %s: local identity %q and policy already provisioned on a different route", ConfigFileName, local.Identity)
363		}
364		pooledRoutes[key] = address
365		config.byAddress[address] = local.Route
366		config.local = local
367	}
368	return config, nil
369}
370
371// parseLocal strictly parses the optional local binding. A nil document
372// provisions no local route. The identity and policy are required and
373// validated; the route must be a local Unix daemonmux carriage, because the
374// local daemon is reached in-process over a private Unix socket and never
375// through an SSH or QUIC carriage. The display origin is sanitized and
376// defaults to the broker-owned value "local", so an absent or wholly
377// undisplayable origin never yields an empty publication.
378func parseLocal(document *localDocument) (*LocalBinding, error) {
379	if document == nil {
380		return nil, nil
381	}
382	identity := ports.BrokerDaemonIdentity(document.Identity)
383	if err := identity.Validate(); err != nil {
384		return nil, err
385	}
386	policy, err := document.Policy.validate()
387	if err != nil {
388		return nil, err
389	}
390	route, err := parseRoute(document.Route)
391	if err != nil {
392		return nil, err
393	}
394	if !route.IsLocal() {
395		return nil, errors.New("local route must be a Unix daemonmux carriage")
396	}
397	origin := ports.SanitizeBrokerDisplayText(document.DisplayOrigin)
398	if origin == "" {
399		origin = LocalDisplayOrigin
400	}
401	if len(origin) > ports.BrokerMaxDisplayOriginBytes {
402		return nil, fmt.Errorf("local display origin exceeds %d bytes", ports.BrokerMaxDisplayOriginBytes)
403	}
404	return &LocalBinding{Identity: identity, DisplayOrigin: origin, Policy: policy, Route: route}, nil
405}
406
407// appendUnixRouteOnce adds a local Unix route unless an identical route (the
408// same opaque address, hence the same bounded contents) is already present, so
409// endpoint aliases of one route never make LocalMuxRoute report more than one
410// provisioned route.
411func appendUnixRouteOnce(routes []Route, route Route) []Route {
412	for _, existing := range routes {
413		if existing.Address() == route.Address() {
414			return routes
415		}
416	}
417	return append(routes, route)
418}
419
420// LocalDisplayOrigin is the broker-owned default presentation hint for the
421// broker's own machine daemon when the configuration provisions none.
422const LocalDisplayOrigin = "local"
423
424// LocalBinding returns the provisioned broker-owned local binding and whether
425// one exists. A nil configuration or one without a local object provisions
426// nothing, and a local request is then refused.
427func (c *Config) LocalBinding() (LocalBinding, bool) {
428	if c == nil || c.local == nil {
429		return LocalBinding{}, false
430	}
431	return *c.local, true
432}
433
434// Endpoints returns the configured endpoints in file order. The result is a
435// copy; the configuration is immutable.
436func (c *Config) Endpoints() []string {
437	if c == nil {
438		return nil
439	}
440	return append([]string(nil), c.order...)
441}
442
443// Registration returns the immutable configured authority for one endpoint.
444func (c *Config) Registration(endpoint string) (Registration, bool) {
445	if c == nil {
446		return Registration{}, false
447	}
448	registration, ok := c.byEndpoint[endpoint]
449	return registration, ok
450}
451
452// IdleGrace returns the provisioned effective idle grace and whether the
453// configuration provisioned one. A nil configuration provisions nothing.
454func (c *Config) IdleGrace() (time.Duration, bool) {
455	if c == nil {
456		return 0, false
457	}
458	return c.idleGrace, c.hasIdleGrace
459}
460
461// WarmTransports returns how many idle physical transports the broker keeps
462// warm for reuse. Zero disables warm reuse. A nil configuration uses the
463// default.
464func (c *Config) WarmTransports() int {
465	if c == nil {
466		return DefaultWarmTransports
467	}
468	return c.warmTransports
469}
470
471// WarmIdleTimeout returns how long an idle physical transport stays warm.
472// Zero means no age bound.
473func (c *Config) WarmIdleTimeout() time.Duration {
474	if c == nil {
475		return 0
476	}
477	return c.warmIdleTimeout
478}
479
480// RouteByAddress resolves one opaque pool address back to the route it was
481// derived from. The address is produced only by this configuration's own
482// routes, so an unknown address is refused rather than dialed.
483func (c *Config) RouteByAddress(address string) (Route, bool) {
484	if c == nil {
485		return Route{}, false
486	}
487	route, ok := c.byAddress[address]
488	return route, ok
489}
490
491// Resolver returns the immutable endpoint resolver over this configuration.
492func (c *Config) Resolver() *Resolver {
493	if c == nil {
494		return &Resolver{byEndpoint: map[string]Registration{}}
495	}
496	byEndpoint := make(map[string]Registration, len(c.byEndpoint))
497	for endpoint, registration := range c.byEndpoint {
498		registration.Route.argv = append([]string(nil), registration.Route.argv...)
499		byEndpoint[endpoint] = registration
500	}
501	var local *LocalBinding
502	if c.local != nil {
503		copy := *c.local
504		copy.Route.argv = append([]string(nil), copy.Route.argv...)
505		local = &copy
506	}
507	return &Resolver{byEndpoint: byEndpoint, local: local}
508}
509
510// configDocument is the strict on-disk shape. Unknown fields are refused by the
511// decoder, so the file cannot smuggle additional behavior.
512type configDocument struct {
513	Marker string `json:"marker"`
514	// Local optionally provisions the broker-owned local binding. It is the
515	// only source of the local identity, policy, and daemonmux route.
516	Local *localDocument `json:"local,omitempty"`
517	// IdleGrace optionally provisions the sandbox's effective idle grace as a Go
518	// duration string (for example "90s"). It is the single deterministic source
519	// for the idle grace, because a status probe cannot read a running broker's
520	// effective grace through the broker IPC protocol.
521	IdleGrace string `json:"idleGrace,omitempty"`
522	// WarmTransports optionally bounds idle physical transports kept warm for
523	// reuse (0 through MaxWarmTransports; 0 disables warm reuse).
524	WarmTransports *int `json:"warmTransports,omitempty"`
525	// WarmIdleTimeout optionally expires a warm transport: "off" (no age
526	// bound, the default) or a positive Go duration up to MaxIdleGrace.
527	WarmIdleTimeout string            `json:"warmIdleTimeout,omitempty"`
528	Registrations   []registrationRaw `json:"registrations"`
529}
530
531func (d configDocument) warm() (int, time.Duration, error) {
532	count := DefaultWarmTransports
533	if d.WarmTransports != nil {
534		count = *d.WarmTransports
535		if count < 0 || count > MaxWarmTransports {
536			return 0, 0, fmt.Errorf("brokerconfig: %s: warmTransports must be between 0 and %d", ConfigFileName, MaxWarmTransports)
537		}
538	}
539	if d.WarmIdleTimeout == "" || d.WarmIdleTimeout == "off" {
540		return count, 0, nil
541	}
542	timeout, err := time.ParseDuration(d.WarmIdleTimeout)
543	if err != nil || timeout <= 0 || timeout > MaxIdleGrace {
544		return 0, 0, fmt.Errorf("brokerconfig: %s: warmIdleTimeout %q must be off or a positive duration of at most %s", ConfigFileName, d.WarmIdleTimeout, MaxIdleGrace)
545	}
546	return count, timeout, nil
547}
548
549// localDocument is the strict on-disk shape of the broker-owned local binding.
550// The route must be a local Unix daemonmux carriage, the identity and policy
551// are required, and the display origin is optional (defaulting to "local").
552type localDocument struct {
553	Identity      string          `json:"identity"`
554	DisplayOrigin string          `json:"displayOrigin,omitempty"`
555	Route         json.RawMessage `json:"route"`
556	Policy        policyRaw       `json:"policy"`
557}
558
559// registrationRaw is the raw JSON registration before semantic validation.
560type registrationRaw struct {
561	Endpoint    string          `json:"endpoint"`
562	Incarnation string          `json:"incarnation"`
563	Generation  uint64          `json:"generation"`
564	Identity    string          `json:"identity"`
565	Route       json.RawMessage `json:"route"`
566	Policy      policyRaw       `json:"policy"`
567}
568
569// policyRaw is the raw JSON policy before semantic validation.
570type policyRaw struct {
571	ProtocolVersion      uint16 `json:"protocolVersion"`
572	CatalogSchemaVersion uint16 `json:"catalogSchemaVersion"`
573	EnvironmentPolicy    string `json:"environmentPolicy"`
574	Transport            string `json:"transport"`
575	Trust                string `json:"trust"`
576	Launch               string `json:"launch"`
577	Isolation            string `json:"isolation"`
578}
579
580func (r registrationRaw) validate() (Registration, error) {
581	var registration domain.RemoteRegistration
582	registration.Endpoint = r.Endpoint
583	if err := domain.ValidateRemoteHostTarget(r.Endpoint); err != nil {
584		return Registration{}, err
585	}
586	if len(r.Incarnation) != 32 {
587		return Registration{}, errors.New("incarnation must be 32 hexadecimal characters")
588	}
589	decoded, err := hex.DecodeString(r.Incarnation)
590	if err != nil {
591		return Registration{}, fmt.Errorf("incarnation: %w", err)
592	}
593	copy(registration.Incarnation[:], decoded)
594	registration.Generation = domain.RemoteGeneration(r.Generation)
595	if err := registration.Validate(); err != nil {
596		return Registration{}, err
597	}
598	identity := ports.BrokerDaemonIdentity(r.Identity)
599	if err := identity.Validate(); err != nil {
600		return Registration{}, err
601	}
602	policy, err := r.Policy.validate()
603	if err != nil {
604		return Registration{}, err
605	}
606	route, err := parseRoute(r.Route)
607	if err != nil {
608		return Registration{}, err
609	}
610	return Registration{Registration: registration, Identity: identity, Policy: policy, Route: route}, nil
611}
612
613func (p policyRaw) validate() (ports.BrokerPolicy, error) {
614	var environment protocol.EnvironmentPolicy
615	switch p.EnvironmentPolicy {
616	case "client-owned":
617		environment = protocol.EnvironmentPolicyClientOwned
618	case "daemon-owned":
619		environment = protocol.EnvironmentPolicyDaemonOwned
620	default:
621		return ports.BrokerPolicy{}, fmt.Errorf("policy environmentPolicy %q is invalid", p.EnvironmentPolicy)
622	}
623	policy := ports.BrokerPolicy{
624		ProtocolVersion:      p.ProtocolVersion,
625		CatalogSchemaVersion: p.CatalogSchemaVersion,
626		EnvironmentPolicy:    environment,
627		Transport:            p.Transport,
628		Trust:                p.Trust,
629		Launch:               p.Launch,
630		Isolation:            p.Isolation,
631	}
632	if err := policy.Validate(); err != nil {
633		return ports.BrokerPolicy{}, err
634	}
635	return policy, nil
636}
637
638// rejectRouteOverlap refuses a provisioned Unix mux route that equals, contains,
639// or sits inside a reserved production runtime or state directory. A route is an
640// address the broker connector dials, so a route overlapping production would
641// let the isolated offline sandbox reach a production endpoint even though the
642// sandbox root itself is outside production. Reserved entries are cleaned
643// absolute paths; empty entries are ignored.
644func rejectRouteOverlap(route string, reserved []string) error {
645	for _, other := range reserved {
646		if other == "" {
647			continue
648		}
649		other = filepath.Clean(other)
650		if overlaps(route, other) {
651			return fmt.Errorf("route %q overlaps production path %q", route, other)
652		}
653	}
654	return nil
655}
656
657// validateRoute refuses anything but an absolute, cleaned Unix path without
658// symlinked components, so a provisioned route can never be reshaped by the
659// request path or escape through a link.
660func validateRoute(route string) error {
661	if route == "" {
662		return errors.New("route is empty")
663	}
664	if !filepath.IsAbs(route) {
665		return fmt.Errorf("route %q is not absolute", route)
666	}
667	if filepath.Clean(route) != route {
668		return fmt.Errorf("route %q is not clean", route)
669	}
670	return rejectSymlinkedComponents(route)
671}
672
673// readConfig reads one bounded regular owner-only config file without following
674// a symlink.
675func readConfig(path string) ([]byte, error) {
676	fd, err := syscall.Open(path, syscall.O_RDONLY|syscall.O_CLOEXEC|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
677	if err != nil {
678		if errors.Is(err, syscall.ELOOP) {
679			return nil, fmt.Errorf("brokerconfig: %s is a symlink", path)
680		}
681		return nil, fmt.Errorf("brokerconfig: open %s: %w", path, err)
682	}
683	file := os.NewFile(uintptr(fd), path)
684	defer file.Close()
685	info, err := file.Stat()
686	if err != nil {
687		return nil, fmt.Errorf("brokerconfig: stat %s: %w", path, err)
688	}
689	if !info.Mode().IsRegular() {
690		return nil, fmt.Errorf("brokerconfig: %s is not a regular file", path)
691	}
692	if info.Mode().Perm()&0o077 != 0 {
693		return nil, fmt.Errorf("brokerconfig: %s has permissions %04o, want owner-only", path, info.Mode().Perm())
694	}
695	if info.Size() > MaxConfigBytes {
696		return nil, fmt.Errorf("brokerconfig: %s exceeds %d bytes", path, MaxConfigBytes)
697	}
698	raw, err := io.ReadAll(io.LimitReader(file, MaxConfigBytes+1))
699	if err != nil {
700		return nil, fmt.Errorf("brokerconfig: read %s: %w", path, err)
701	}
702	if len(raw) > MaxConfigBytes {
703		return nil, fmt.Errorf("brokerconfig: %s exceeds %d bytes", path, MaxConfigBytes)
704	}
705	return raw, nil
706}
707
708// decodeStrict rejects duplicate keys, unknown fields, trailing JSON, invalid
709// UTF-8, and excessive nesting, then decodes once. Byte bounds are applied
710// before decoding.
711func decodeStrict(raw []byte, dst any) error {
712	if len(raw) > MaxConfigBytes || !utf8.Valid(raw) {
713		return errors.New("invalid JSON size or encoding")
714	}
715	scan := json.NewDecoder(bytes.NewReader(raw))
716	if err := scanJSON(scan, 0); err != nil {
717		return err
718	}
719	if _, err := scan.Token(); err != io.EOF {
720		return errors.New("trailing JSON")
721	}
722	decoder := json.NewDecoder(bytes.NewReader(raw))
723	decoder.DisallowUnknownFields()
724	return decoder.Decode(dst)
725}
726
727// scanJSON walks one JSON value, rejecting duplicate keys and excessive depth.
728func scanJSON(decoder *json.Decoder, depth int) error {
729	if depth > 64 {
730		return errors.New("JSON nesting limit")
731	}
732	token, err := decoder.Token()
733	if err != nil {
734		return err
735	}
736	delimiter, ok := token.(json.Delim)
737	if !ok {
738		return nil
739	}
740	switch delimiter {
741	case '{':
742		seen := make(map[string]bool)
743		for decoder.More() {
744			key, err := decoder.Token()
745			if err != nil {
746				return err
747			}
748			name, ok := key.(string)
749			if !ok || seen[name] {
750				return errors.New("duplicate JSON key")
751			}
752			seen[name] = true
753			if err := scanJSON(decoder, depth+1); err != nil {
754				return err
755			}
756		}
757		_, err = decoder.Token()
758		return err
759	case '[':
760		for decoder.More() {
761			if err := scanJSON(decoder, depth+1); err != nil {
762				return err
763			}
764		}
765		_, err = decoder.Token()
766		return err
767	default:
768		return errors.New("unexpected JSON delimiter")
769	}
770}

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 12

Tags 11

↑↓ moveenter openesc close