1package remote
2
3import (
4 "context"
5 "encoding/base64"
6 "errors"
7 "fmt"
8 "os/exec"
9 "time"
10
11 "github.com/bnema/vev/internal/adapters/sshstdio"
12 "github.com/bnema/vev/internal/domain"
13 "github.com/bnema/vev/internal/ports"
14)
15
16const (
17 remotePreviewCommandTimeout = 5 * time.Second
18 remotePreviewWaitDelay = 500 * time.Millisecond
19 remotePreviewMaxDiagnostic = 4 << 10
20)
21
22var (
23 errRemotePreviewSSH = errors.New("remote preview: ssh command failed")
24 errRemotePreviewTooLarge = errors.New("remote preview: output exceeds size limit")
25 errRemotePreviewIdentity = errors.New("remote preview: response identity mismatch")
26)
27
28// PreviewClient invokes the hidden remote preview command over SSH. The
29// command receives only a base64url request and stdout is decoded as one
30// bounded binary response.
31type PreviewClient struct {
32 command func(ctx context.Context, name string, args ...string) *exec.Cmd
33 timeout time.Duration
34}
35
36var _ ports.RemotePreviewClient = (*PreviewClient)(nil)
37
38func NewPreviewClient() ports.RemotePreviewClient {
39 return &PreviewClient{command: exec.CommandContext}
40}
41
42func (c *PreviewClient) Preview(ctx context.Context, target domain.RemoteSessionTarget, width, height uint16) (ports.RemotePreview, error) {
43 request := ports.RemotePreviewRequest{Version: ports.RemotePreviewSchemaVersion, Target: target, Width: width, Height: height}
44 payload := ports.MarshalRemotePreviewRequest(request)
45 if payload == nil {
46 return ports.RemotePreview{}, ports.ErrInvalidRemotePreviewRequest
47 }
48 runCtx, cancel := context.WithTimeout(ctx, c.previewTimeout())
49 defer cancel()
50 command := c.command
51 if command == nil {
52 command = exec.CommandContext
53 }
54 encoded := base64.RawURLEncoding.EncodeToString(payload)
55 spec := sshstdio.BuildCommandForRemoteCommand(target.Endpoint, "vev", "_remote-preview", encoded)
56 cmd := command(runCtx, spec.Path, spec.Args...)
57 stdout := boundedBuffer{limit: ports.RemotePreviewMaxBytes}
58 stderr := boundedBuffer{limit: remotePreviewMaxDiagnostic}
59 cmd.Stdout = &stdout
60 cmd.Stderr = &stderr
61 cmd.WaitDelay = remotePreviewWaitDelay
62 if err := cmd.Run(); err != nil {
63 if ctxErr := runCtx.Err(); ctxErr != nil {
64 if parentErr := ctx.Err(); parentErr != nil {
65 return ports.RemotePreview{}, parentErr
66 }
67 return ports.RemotePreview{}, ports.ErrRemotePreviewTimeout
68 }
69 if stdout.overflow || stderr.overflow {
70 return ports.RemotePreview{}, errRemotePreviewTooLarge
71 }
72 if diagnostic := sanitizeCatalogDiagnostic(string(stderr.Bytes())); diagnostic != "" {
73 return ports.RemotePreview{}, fmt.Errorf("%w: %v: %s", errRemotePreviewSSH, err, diagnostic)
74 }
75 return ports.RemotePreview{}, fmt.Errorf("%w: %v", errRemotePreviewSSH, err)
76 }
77 if stdout.overflow || stderr.overflow {
78 return ports.RemotePreview{}, errRemotePreviewTooLarge
79 }
80 preview, err := ports.UnmarshalRemotePreview(stdout.Bytes())
81 if err != nil {
82 return ports.RemotePreview{}, err
83 }
84 if preview.Status != ports.RemotePreviewOK {
85 return ports.RemotePreview{}, fmt.Errorf("remote preview unavailable: status %d", preview.Status)
86 }
87 if preview.LifecycleID != target.LifecycleID || preview.TabID != target.LiveTabID {
88 return ports.RemotePreview{}, errRemotePreviewIdentity
89 }
90 return preview, nil
91}
92
93func (c *PreviewClient) previewTimeout() time.Duration {
94 if c != nil && c.timeout > 0 {
95 return c.timeout
96 }
97 return remotePreviewCommandTimeout
98}