A Transparent and Verifiable Way to Sync the AT Protocol's PLC Directory
1// detector/script.go
2package detector
3
4import (
5 "bufio"
6 "context"
7 "fmt"
8 "net"
9 "os"
10 "os/exec"
11 "path/filepath"
12 "strings"
13 "time"
14
15 "github.com/goccy/go-json"
16
17 "tangled.org/atscan.net/plcbundle/plc"
18)
19
20// ScriptDetector runs a JavaScript detector via Unix socket
21type ScriptDetector struct {
22 name string
23 scriptPath string
24 socketPath string
25 serverCmd *exec.Cmd
26 conn net.Conn
27 writer *bufio.Writer
28 reader *bufio.Reader
29}
30
31func NewScriptDetector(scriptPath string) (*ScriptDetector, error) {
32 if _, err := exec.LookPath("bun"); err != nil {
33 return nil, fmt.Errorf("bun runtime not found in PATH")
34 }
35
36 absPath, err := filepath.Abs(scriptPath)
37 if err != nil {
38 return nil, fmt.Errorf("invalid script path: %w", err)
39 }
40
41 name := strings.TrimSuffix(filepath.Base(scriptPath), filepath.Ext(scriptPath))
42 socketPath := filepath.Join(os.TempDir(), fmt.Sprintf("detector-%d-%s.sock", os.Getpid(), name))
43
44 sd := &ScriptDetector{
45 name: name,
46 scriptPath: absPath,
47 socketPath: socketPath,
48 }
49
50 if err := sd.startServer(); err != nil {
51 return nil, err
52 }
53
54 return sd, nil
55}
56
57func (d *ScriptDetector) Name() string { return d.name }
58func (d *ScriptDetector) Description() string { return "JavaScript detector: " + d.name }
59func (d *ScriptDetector) Version() string { return "1.0.0" }
60
61func (d *ScriptDetector) startServer() error {
62 userCode, err := os.ReadFile(d.scriptPath)
63 if err != nil {
64 return fmt.Errorf("failed to read script: %w", err)
65 }
66
67 wrapperScript := d.createSocketWrapper(string(userCode))
68 os.Remove(d.socketPath)
69
70 d.serverCmd = exec.Command("bun", "run", "-", d.socketPath)
71 d.serverCmd.Stdout = os.Stderr
72 d.serverCmd.Stderr = os.Stderr
73
74 stdin, err := d.serverCmd.StdinPipe()
75 if err != nil {
76 return fmt.Errorf("failed to create stdin pipe: %w", err)
77 }
78
79 if err := d.serverCmd.Start(); err != nil {
80 return fmt.Errorf("failed to start server: %w", err)
81 }
82
83 stdin.Write([]byte(wrapperScript))
84 stdin.Close()
85
86 if err := d.connectToServer(); err != nil {
87 d.serverCmd.Process.Kill()
88 os.Remove(d.socketPath)
89 return err
90 }
91
92 return nil
93}
94
95func (d *ScriptDetector) createSocketWrapper(userCode string) string {
96 return fmt.Sprintf(`// Auto-generated socket server wrapper
97// User's detect function
98%s
99
100// Unix socket server
101const socketPath = process.argv[2];
102
103try {
104 await Bun.file(socketPath).unlink();
105} catch {}
106
107const server = Bun.listen({
108 unix: socketPath,
109 socket: {
110 data(socket, data) {
111 try {
112 const operation = JSON.parse(data.toString());
113 const labels = detect({ op: operation }) || [];
114 socket.write(JSON.stringify({ labels }) + '\n');
115 } catch (error) {
116 socket.write(JSON.stringify({ labels: [], error: error.message }) + '\n');
117 }
118 },
119 error(socket, error) {},
120 close(socket) {}
121 }
122});
123
124console.error('Detector server ready on socket:', socketPath);
125`, userCode)
126}
127
128func (d *ScriptDetector) connectToServer() error {
129 maxRetries := 50
130 for i := 0; i < maxRetries; i++ {
131 conn, err := net.Dial("unix", d.socketPath)
132 if err == nil {
133 d.conn = conn
134 d.writer = bufio.NewWriter(conn)
135 d.reader = bufio.NewReader(conn)
136 return nil
137 }
138 time.Sleep(50 * time.Millisecond)
139 }
140 return fmt.Errorf("failed to connect to socket within timeout")
141}
142
143func (d *ScriptDetector) Detect(ctx context.Context, op plc.PLCOperation) (*Match, error) {
144 if d.conn == nil {
145 return nil, fmt.Errorf("not connected to server")
146 }
147
148 // Use RawJSON directly
149 data := op.RawJSON
150 if len(data) == 0 {
151 var err error
152 data, err = json.Marshal(op)
153 if err != nil {
154 return nil, fmt.Errorf("failed to serialize operation: %w", err)
155 }
156 }
157
158 if _, err := d.writer.Write(data); err != nil {
159 return nil, fmt.Errorf("failed to write to socket: %w", err)
160 }
161 if _, err := d.writer.WriteString("\n"); err != nil {
162 return nil, fmt.Errorf("failed to write newline: %w", err)
163 }
164 if err := d.writer.Flush(); err != nil {
165 return nil, fmt.Errorf("failed to flush: %w", err)
166 }
167
168 line, err := d.reader.ReadString('\n')
169 if err != nil {
170 return nil, fmt.Errorf("failed to read response: %w", err)
171 }
172
173 var result struct {
174 Labels []string `json:"labels"`
175 Error string `json:"error,omitempty"`
176 }
177
178 if err := json.Unmarshal([]byte(line), &result); err != nil {
179 return nil, fmt.Errorf("failed to parse response: %w", err)
180 }
181
182 if result.Error != "" {
183 return nil, fmt.Errorf("detector error: %s", result.Error)
184 }
185
186 if len(result.Labels) == 0 {
187 return nil, nil
188 }
189
190 return &Match{
191 Reason: strings.Join(result.Labels, "_"),
192 Category: "custom",
193 Confidence: 0.95,
194 Note: fmt.Sprintf("Labels: %s", strings.Join(result.Labels, ", ")),
195 Metadata: map[string]interface{}{
196 "labels": result.Labels,
197 "detector": d.name,
198 },
199 }, nil
200}
201
202func (d *ScriptDetector) Close() error {
203 if d.conn != nil {
204 d.conn.Close()
205 d.conn = nil
206 }
207
208 if d.serverCmd != nil && d.serverCmd.Process != nil {
209 d.serverCmd.Process.Kill()
210 d.serverCmd.Wait()
211 }
212
213 os.Remove(d.socketPath)
214 return nil
215}