Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion protocol/wire/frame.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@
package wire

import (
"bufio"
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"strconv"
)

Expand Down Expand Up @@ -657,7 +659,17 @@ func DecodeRequest(line []byte) (Request, error) {
return dec(line)
}

// DecodeResponse parses one frame into its type's concrete Go type.
// NewFrameScanner wraps r for newline-delimited frames capped at MaxFrame.
// No pre-sized buffer: bulk frames outgrow any fixed start anyway.
func NewFrameScanner(r io.Reader) *bufio.Scanner {
sc := bufio.NewScanner(r)
sc.Buffer(nil, MaxFrame)
return sc
}

// DecodeResponse parses one frame into its type's concrete Go type. Byte
// fields are freshly allocated per frame, so callers may retain them; pooling
// them would require a copy-out at every retention site first.
func DecodeResponse(line []byte) (Response, error) {
typ, err := frameTag(line, respTagHead, "type")
if err != nil {
Expand Down
8 changes: 1 addition & 7 deletions sandboxd/engine/silkd.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func (e *Engine) dialSilkdSession(ctx context.Context, vsockSocket string) (*sil
}
return &silkdSession{
conn: conn,
sc: silkdScanner(conn),
sc: wire.NewFrameScanner(conn),
stop: context.AfterFunc(ctx, func() { _ = conn.Close() }),
}, nil
}
Expand Down Expand Up @@ -57,9 +57,3 @@ func (s *silkdSession) close() {
s.stop()
_ = s.conn.Close()
}

func silkdScanner(conn net.Conn) *bufio.Scanner {
sc := bufio.NewScanner(conn)
sc.Buffer(make([]byte, 64*1024), wire.MaxFrame)
return sc
}
3 changes: 2 additions & 1 deletion sandboxd/mesh/mesh.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,8 @@ func (m *Mesh) Join(seeds []string) error {
// UpdateSelf republishes this node's warm-pool counts and promoted-template
// set, bumping the epoch so peers adopt the new view. An unchanged view is
// not republished — the periodic tick would otherwise gossip a fresh epoch
// every second for nothing.
// every second for nothing. templates must arrive sorted: the unchanged
// compare is order-sensitive.
func (m *Mesh) UpdateSelf(ctx context.Context, pools map[string]int, templates []string) {
m.updateMu.Lock()
defer m.updateMu.Unlock()
Expand Down
18 changes: 18 additions & 0 deletions sandboxd/pool/promote_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -261,3 +261,21 @@ func TestPromoteRefusesCrossTenantOverwrite(t *testing.T) {
t.Errorf("root replace: %v, want ok", err)
}
}

func TestTemplateHashesSortedForMeshCompare(t *testing.T) {
eng := newFakeEngine()
m := newTestManager(t, eng)
parent := mustClaim(t, m, testKey)
for _, name := range []string{"tpl:a", "tpl:b", "tpl:c", "tpl:d"} {
if _, err := m.Promote(t.Context(), parent.ID, Cred{Token: parent.Token}, name, ""); err != nil {
t.Fatalf("Promote %s: %v", name, err)
}
}
hashes := m.TemplateHashes()
if len(hashes) != 4 {
t.Fatalf("got %d hashes, want 4: %v", len(hashes), hashes)
}
if !slices.IsSorted(hashes) {
t.Errorf("TemplateHashes not sorted: %v", hashes)
}
}
5 changes: 4 additions & 1 deletion sandboxd/pool/template.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"os"
"path/filepath"
"slices"
"sync"
"time"

Expand Down Expand Up @@ -114,7 +115,8 @@ func (m *Manager) DeleteTemplate(ctx context.Context, key types.PoolKey, tenant

// TemplateHashes lists the promoted-template key hashes for the mesh's
// template gossip, from the in-memory set — the 1s gossip tick never
// touches the store backend.
// touches the store backend. Sorted: the mesh's unchanged-guard compares
// order-sensitively, and map iteration order would defeat it every tick.
func (m *Manager) TemplateHashes() []string {
m.mu.Lock()
pooled := make(map[string]struct{}, len(m.pools))
Expand All @@ -131,6 +133,7 @@ func (m *Manager) TemplateHashes() []string {
}
}
m.tplMu.Unlock()
slices.Sort(hashes)
return hashes
}

Expand Down
14 changes: 11 additions & 3 deletions sdk/go/files.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"fmt"
"io"
"slices"

"github.com/cocoonstack/sandbox/protocol/wire"
"github.com/cocoonstack/sandbox/sdk/go/silkd"
Expand All @@ -21,12 +22,19 @@ func (s *Sandbox) WriteFile(ctx context.Context, path string, data []byte, mode

// ReadFile returns the contents of path.
func (s *Sandbox) ReadFile(ctx context.Context, path string) ([]byte, error) {
var out []byte
// Retaining b is safe: fastBulk decodes each frame into a fresh buffer.
var chunks [][]byte
err := s.downloadRPC(ctx, &wire.FsRead{Path: path}, func(b []byte) error {
out = append(out, b...)
chunks = append(chunks, b)
return nil
})
return out, err
if err != nil || len(chunks) == 0 {
return nil, err
}
if len(chunks) == 1 {
return chunks[0], nil
}
return slices.Concat(chunks...), nil
}

// ListDir returns the entries of a directory (batched frames are concatenated).
Expand Down
17 changes: 17 additions & 0 deletions sdk/go/files_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package sandbox

import (
"bytes"
"testing"

"github.com/cocoonstack/sandbox/sdk/go/silkd/silkdtest"
Expand Down Expand Up @@ -94,6 +95,22 @@ func TestSessionLifecycle(t *testing.T) {

// fakeSandbox wires a Sandbox whose data plane is served by a temp-dir-backed
// silkd Fake behind a hijacking agent endpoint.
func TestReadFileMultiChunk(t *testing.T) {
sb := fakeSandbox(t)
ctx := t.Context()
want := bytes.Repeat([]byte("abcdefgh"), 80*1024)
if err := sb.WriteFile(ctx, "/big.bin", want, nil); err != nil {
t.Fatalf("write: %v", err)
}
got, err := sb.ReadFile(ctx, "/big.bin")
if err != nil {
t.Fatalf("read: %v", err)
}
if !bytes.Equal(got, want) {
t.Errorf("read %d bytes, want %d", len(got), len(want))
}
}

func fakeSandbox(t *testing.T) *Sandbox {
t.Helper()
fake := silkdtest.NewFake(t.TempDir())
Expand Down
13 changes: 13 additions & 0 deletions sdk/go/sandbox_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,19 @@ func TestRunRejectedUpgrade(t *testing.T) {
}
}

func TestDialAgentRejectsControlChars(t *testing.T) {
ts := newAgentServer(t, func(conn net.Conn) { _ = conn.Close() })
c := testClient(t, ts)
for _, bad := range []struct{ id, token string }{
{"sb\r\nInjected: 1", "tok"},
{"sb_1", "tok\r\nInjected: 1"},
} {
if _, err := c.dialAgent(t.Context(), c.addr, bad.id, bad.token); err == nil {
t.Errorf("dialAgent(%q, %q) succeeded, want control-character error", bad.id, bad.token)
}
}
}

// newAgentServer serves the agent endpoint by hijacking and handing the conn
// to serve (a fake silkd), mirroring sandboxd's relay from the SDK's
// perspective.
Expand Down
4 changes: 1 addition & 3 deletions sdk/go/silkd/conn.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,7 @@ type Conn struct {

// NewConn wraps rwc; the caller keeps ownership of closing via Close.
func NewConn(rwc io.ReadWriteCloser) *Conn {
sc := bufio.NewScanner(rwc)
sc.Buffer(make([]byte, 64*1024), wire.MaxFrame)
return &Conn{rwc: rwc, sc: sc}
return &Conn{rwc: rwc, sc: wire.NewFrameScanner(rwc)}
}

// Send writes one request frame.
Expand Down
9 changes: 8 additions & 1 deletion sdk/go/silkd/silkdtest/fake.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ import (
// and tracking sessions, so an SDK write-then-read round-trips through it.
// exec/info reuse the stateless handlers. It exists for host-side unit tests;
// the authoritative fs/session behavior is silkd's own Rust test suite.
// readChunk mirrors silkd's BULK_CHUNK so downloads exercise real framing.
const readChunk = 256 * 1024

type Fake struct {
Root string

Expand Down Expand Up @@ -119,7 +122,11 @@ func (f *Fake) fsRead(conn net.Conn, path string) {
errFrame(conn, wire.KindNotFound, err.Error())
return
}
send(conn, &wire.DataResp{Data: data})
for len(data) > 0 {
n := min(readChunk, len(data))
send(conn, &wire.DataResp{Data: data[:n]})
data = data[n:]
}
send(conn, wire.Done{})
}

Expand Down
16 changes: 8 additions & 8 deletions sdk/go/upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"net"
"net/http"
"strings"
"time"
)

Expand All @@ -24,21 +25,20 @@ func (c *Client) dialAgent(ctx context.Context, addr, id, token string) (net.Con
_ = raw.SetDeadline(deadline)
}

req, err := http.NewRequest(http.MethodGet, "http://"+addr+"/v1/sandboxes/"+id+"/agent", nil)
if err != nil {
// id/token interpolate into the raw request; CR/LF would inject headers.
if strings.ContainsAny(id, "\r\n\x00") || strings.ContainsAny(token, "\r\n\x00") {
_ = raw.Close()
return nil, err
return nil, fmt.Errorf("agent upgrade: id or token contains a control character")
}
req.Header.Set("Connection", "Upgrade")
req.Header.Set("Upgrade", "silkd")
req.Header.Set("Authorization", "Bearer "+token)
if err = req.Write(raw); err != nil {
req := "GET /v1/sandboxes/" + id + "/agent HTTP/1.1\r\nHost: " + addr +
"\r\nConnection: Upgrade\r\nUpgrade: silkd\r\nAuthorization: Bearer " + token + "\r\n\r\n"
if _, err = raw.Write([]byte(req)); err != nil {
_ = raw.Close()
return nil, fmt.Errorf("write upgrade request: %w", err)
}

br := bufio.NewReader(raw)
resp, err := http.ReadResponse(br, req)
resp, err := http.ReadResponse(br, nil)
if err != nil {
_ = raw.Close()
if ctxErr := ctx.Err(); ctxErr != nil {
Expand Down
2 changes: 2 additions & 0 deletions sdk/python/cocoonsandbox/conn.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ def dial_agent(addr: str, sandbox_id: str, token: str, timeout: float) -> Conn:
raise APIError("agent upgrade", 0, f"{name} contains a control character")
host, port = addr.rsplit(":", 1)
sock = socket.create_connection((host, int(port)), timeout=timeout)
# Nagle off: exec/write send small back-to-back frames before the first read.
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
reader = None
try:
request = (
Expand Down
2 changes: 1 addition & 1 deletion sdk/python/cocoonsandbox/frames.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

PROTO_VERSION = 1
MAX_FRAME = 8 * 1024 * 1024
FS_CHUNK = 32 * 1024
FS_CHUNK = 256 * 1024 # silkd's per-frame chunk size, distinct from BULK_CHUNK below
# Bulk streams (push tars, port bytes) chunk larger — fewer frames for the
# same bytes, still far under MAX_FRAME after base64; mirrors the Go SDK.
BULK_CHUNK = 1 << 20
Expand Down
27 changes: 18 additions & 9 deletions silkd/src/proto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -429,19 +429,28 @@ pub async fn write_chunk_frame<W: AsyncWrite + Unpin>(
kind: &str,
data: &[u8],
) -> io::Result<()> {
buf.clear();
buf.extend_from_slice(b"{\"type\":\"");
buf.extend_from_slice(kind.as_bytes());
buf.extend_from_slice(b"\",\"data\":\"");
let start = buf.len();
const PRE: &[u8] = b"{\"type\":\"";
const MID: &[u8] = b"\",\"data\":\"";
const END: &[u8] = b"\"}\n";
let b64_len = base64::encoded_len(data.len(), true)
.ok_or_else(|| io::Error::other("chunk too large to encode"))?;
buf.resize(start + b64_len, 0);
let total = PRE.len() + kind.len() + MID.len() + b64_len + END.len();
// Grow-only, never cleared: resize's zero-fill runs once at high-water
// instead of per chunk; buf[..total] is fully overwritten below.
if buf.len() < total {
buf.resize(total, 0);
}
let mut at = 0;
for part in [PRE, kind.as_bytes(), MID] {
buf[at..at + part.len()].copy_from_slice(part);
at += part.len();
}
STANDARD
.encode_slice(data, &mut buf[start..])
.encode_slice(data, &mut buf[at..at + b64_len])
.map_err(io::Error::other)?;
buf.extend_from_slice(b"\"}\n");
w.write_all(buf).await?;
at += b64_len;
buf[at..at + END.len()].copy_from_slice(END);
w.write_all(&buf[..total]).await?;
w.flush().await
}

Expand Down
Loading