1// Package visualproc launches the separate visual process and speaks the tiny
2// private daemon<->visual pipe protocol. It never renders and never owns or
3// releases the session lock; the visual does, and reports the outcome.
4//
5// Wire format, both directions (fd 3: daemon->visual, fd 4: visual->daemon):
6//
7// "NAV2" | kind u8 | length u32 BE | payload
8//
9// The magic names the format version: the daemon re-executes its binary as
10// the visual, so an upgrade under a running daemon fails with ErrProtocol.
11//
12// Commands: 1 Fade {black u8, duration i64 ns BE}; 2 Lock {generation u64,
13// then four u32-length-prefixed byte strings: PINSource, PINReference,
14// EnvPIN, Output}. Events: 3 LockConfirmed {generation u64}; 4 LockReleased
15// {generation u64}. Process exit is observed by the launcher, not sent.
16package visualproc
17
18import (
19 "encoding/binary"
20 "errors"
21 "io"
22 "time"
23
24 "github.com/bnema/neferafk/internal/ports"
25)
26
27var ErrProtocol = errors.New("visualproc: invalid frame")
28
29const (
30 kindFade byte = iota + 1
31 kindLock
32 kindConfirmed
33 kindReleased
34 maxPayload = 8 + 4*4 + 2*ports.AuthMaxMetadata + ports.AuthMaxSecret + ports.MaxOutputName
35)
36
37const magic = "NAV2"
38
39func writeFrame(w io.Writer, kind byte, payload []byte) error {
40 buf := make([]byte, 9+len(payload))
41 defer clear(buf)
42 copy(buf, magic)
43 buf[4] = kind
44 binary.BigEndian.PutUint32(buf[5:], uint32(len(payload)))
45 copy(buf[9:], payload)
46 for b := buf; len(b) > 0; {
47 n, err := w.Write(b)
48 if err != nil {
49 return err
50 }
51 b = b[n:]
52 }
53 return nil
54}
55
56func readFrame(r io.Reader) (byte, []byte, error) {
57 var h [9]byte
58 if _, err := io.ReadFull(r, h[:]); err != nil {
59 return 0, nil, err
60 }
61 n := binary.BigEndian.Uint32(h[5:])
62 if string(h[:4]) != magic || n > maxPayload {
63 return 0, nil, ErrProtocol
64 }
65 p := make([]byte, n)
66 if _, err := io.ReadFull(r, p); err != nil {
67 clear(p)
68 return 0, nil, err
69 }
70 return h[4], p, nil
71}
72
73// WriteCommand encodes a daemon->visual command. The caller wipes any secret
74// it owns; the encoded copy is wiped here.
75func WriteCommand(w io.Writer, cmd ports.VisualCommand) error {
76 switch c := cmd.(type) {
77 case ports.VisualFade:
78 p := make([]byte, 9)
79 if c.Black {
80 p[0] = 1
81 }
82 binary.BigEndian.PutUint64(p[1:], uint64(c.Duration))
83 return writeFrame(w, kindFade, p)
84 case ports.VisualLock:
85 a := c.Auth
86 fields := [][]byte{[]byte(a.PINSource), []byte(a.PINReference), a.EnvPIN, []byte(c.Output)}
87 if c.Generation == 0 || uint64(c.Generation) != a.Generation || len(fields[1]) > ports.AuthMaxMetadata || len(fields[2]) > ports.AuthMaxSecret || len(fields[0]) > 16 || len(fields[3]) > ports.MaxOutputName {
88 return ErrProtocol
89 }
90 p := binary.BigEndian.AppendUint64(nil, uint64(c.Generation))
91 for _, f := range fields {
92 p = binary.BigEndian.AppendUint32(p, uint32(len(f)))
93 p = append(p, f...)
94 }
95 defer clear(p)
96 return writeFrame(w, kindLock, p)
97 }
98 return ErrProtocol
99}
100
101// ReadCommand decodes a daemon->visual command (used by the visual process).
102// The returned VisualLock owns EnvPIN and its receiver must wipe it.
103func ReadCommand(r io.Reader) (ports.VisualCommand, error) {
104 kind, p, err := readFrame(r)
105 if err != nil {
106 return nil, err
107 }
108 defer clear(p)
109 switch kind {
110 case kindFade:
111 if len(p) != 9 || p[0] > 1 {
112 return nil, ErrProtocol
113 }
114 d := time.Duration(binary.BigEndian.Uint64(p[1:]))
115 if d < 0 || d > ports.MaxDuration {
116 return nil, ErrProtocol
117 }
118 return ports.VisualFade{Black: p[0] == 1, Duration: d}, nil
119 case kindLock:
120 if len(p) < 8 {
121 return nil, ErrProtocol
122 }
123 gen := binary.BigEndian.Uint64(p)
124 rest := p[8:]
125 var f [4][]byte
126 for i := range f {
127 if len(rest) < 4 {
128 return nil, ErrProtocol
129 }
130 n := binary.BigEndian.Uint32(rest)
131 if uint64(n) > uint64(len(rest)-4) {
132 return nil, ErrProtocol
133 }
134 f[i] = rest[4 : 4+n]
135 rest = rest[4+n:]
136 }
137 if gen == 0 || len(rest) != 0 || len(f[0]) > 16 || len(f[1]) > ports.AuthMaxMetadata || len(f[2]) > ports.AuthMaxSecret || len(f[3]) > ports.MaxOutputName {
138 return nil, ErrProtocol
139 }
140 return ports.VisualLock{Generation: ports.Generation(gen), Auth: ports.AuthBootstrap{Generation: gen, PINSource: ports.PINSource(f[0]), PINReference: string(f[1]), EnvPIN: append([]byte(nil), f[2]...)}, Output: string(f[3])}, nil
141 }
142 return nil, ErrProtocol
143}
144
145// WriteEvent encodes a visual->daemon lifecycle event (used by the visual).
146func WriteEvent(w io.Writer, ev ports.VisualEvent) error {
147 switch e := ev.(type) {
148 case ports.LockConfirmed:
149 return writeFrame(w, kindConfirmed, binary.BigEndian.AppendUint64(nil, uint64(e.Generation)))
150 case ports.LockReleased:
151 return writeFrame(w, kindReleased, binary.BigEndian.AppendUint64(nil, uint64(e.Generation)))
152 }
153 return ErrProtocol
154}
155
156// ReadEvent decodes a visual->daemon event.
157func ReadEvent(r io.Reader) (ports.VisualEvent, error) {
158 kind, p, err := readFrame(r)
159 if err != nil {
160 return nil, err
161 }
162 if len(p) != 8 || binary.BigEndian.Uint64(p) == 0 {
163 return nil, ErrProtocol
164 }
165 gen := ports.Generation(binary.BigEndian.Uint64(p))
166 switch kind {
167 case kindConfirmed:
168 return ports.LockConfirmed{Generation: gen}, nil
169 case kindReleased:
170 return ports.LockReleased{Generation: gen}, nil
171 }
172 return nil, ErrProtocol
173}