diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-14 14:10:28 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-09-14 14:20:25 -0300 |
| commit | 5f24c4a2284a0af84fb9d116f7a39f6a58e42ae9 (patch) | |
| tree | 8679763439492361fe99ea01f5190e5204fa590c /test | |
| download | cloud9-5f24c4a2284a0af84fb9d116f7a39f6a58e42ae9.tar.gz cloud9-5f24c4a2284a0af84fb9d116f7a39f6a58e42ae9.zip | |
Implement base 9P2000 sessions, shared transports, and conformance probes
Diffstat (limited to 'test')
| -rw-r--r-- | test/differential/go.mod | 14 | ||||
| -rw-r--r-- | test/differential/go.sum | 58 | ||||
| -rw-r--r-- | test/differential/main.go | 172 | ||||
| -rw-r--r-- | test/differential/probe.zig | 59 | ||||
| -rw-r--r-- | test/differential/session.go | 45 | ||||
| -rw-r--r-- | test/differential/session.zig | 82 | ||||
| -rw-r--r-- | test/fuzz.zig | 70 | ||||
| -rw-r--r-- | test/quic.zig | 268 | ||||
| -rw-r--r-- | test/transport.zig | 82 |
9 files changed, 850 insertions, 0 deletions
diff --git a/test/differential/go.mod b/test/differential/go.mod new file mode 100644 index 0000000..43f6ef2 --- /dev/null +++ b/test/differential/go.mod @@ -0,0 +1,14 @@ +module cloud9-differential + +go 1.26.5 + +require ( + 9fans.net/go v0.0.7 + github.com/knusbaum/go9p v1.18.0 +) + +require ( + github.com/Plan9-Archive/libauth v0.0.0-20180917063427-d1ca9e94969d // indirect + github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect + github.com/fhs/mux9p v0.3.1 // indirect +) diff --git a/test/differential/go.sum b/test/differential/go.sum new file mode 100644 index 0000000..20c7aea --- /dev/null +++ b/test/differential/go.sum @@ -0,0 +1,58 @@ +9fans.net/go v0.0.2/go.mod h1:lfPdxjq9v8pVQXUMBCx5EO5oLXWQFlKRQgs1kEkjoIM= +9fans.net/go v0.0.7 h1:H5CsYJTf99C8EYAQr+uSoEJnLP/iZU8RmDuhyk30iSM= +9fans.net/go v0.0.7/go.mod h1:Rxvbbc1e+1TyGMjAvLthGTyO97t+6JMQ6ly+Lcs9Uf0= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/Plan9-Archive/libauth v0.0.0-20180917063427-d1ca9e94969d h1:xH/U6K+HYxh1480TkQYRqRO8F2RJsg+R6wFiVJzdldg= +github.com/Plan9-Archive/libauth v0.0.0-20180917063427-d1ca9e94969d/go.mod h1:UKp8dv9aeaZoQFWin7eQXtz89iHly1YAFZNn3MCutmQ= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ= +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/fhs/mux9p v0.3.1 h1:x1UswUWZoA9vrA02jfisndCq3xQm+wrQUxUt5N99E08= +github.com/fhs/mux9p v0.3.1/go.mod h1:F4hwdenmit0WDoNVT2VMWlLJrBVCp/8UhzJa7scfjEQ= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/hanwen/go-fuse v1.0.0/go.mod h1:unqXarDXqzAk0rt98O2tVndEPIpUgLD9+rwFisZH3Ok= +github.com/hanwen/go-fuse/v2 v2.0.3/go.mod h1:0EQM6aH2ctVpvZ6a+onrQ/vaykxh2GH7hy3e13vzTUY= +github.com/knusbaum/go9p v1.18.0 h1:/Y67RNvNKX1ZV1IOdnO1lIetiF0X+CumOyvEc0011GI= +github.com/knusbaum/go9p v1.18.0/go.mod h1:HtMoJKqZUe1Oqag5uJqG5RKQ9gWPSP+wolsnLLv44r8= +github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4= +golang.org/x/exp v0.0.0-20210405174845-4513512abef3/go.mod h1:I6l2HNBLBZEcrOoCpyKLdY2lHoRZ8lI4x60KMCQDft4= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20201217150744-e6ae53a27f4f/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4= +golang.org/x/mobile v0.0.0-20210220033013-bdb1ca9a1e08/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191209134235-331c550502dd/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.1-0.20200828183125-ce943fd02449/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201020230747-6e5568b54d1a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210415045647-66c3f260301c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200117012304-6edc0a871e69/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/test/differential/main.go b/test/differential/main.go new file mode 100644 index 0000000..7a2538d --- /dev/null +++ b/test/differential/main.go @@ -0,0 +1,172 @@ +// Valid-traffic differential conformance and measurement runner. No network targets. +package main + +import ( + p9 "9fans.net/go/plan9" + "bytes" + "encoding/binary" + "encoding/json" + "flag" + "fmt" + other "github.com/knusbaum/go9p/proto" + "io" + "math/rand" + "os" + "os/exec" + "path/filepath" + "runtime" + "time" +) + +type reader struct { + data []byte + chunk, calls int +} + +func (r *reader) Read(p []byte) (int, error) { + r.calls++ + if len(r.data) == 0 { + return 0, io.EOF + } + n := min(len(p), len(r.data), r.chunk) + copy(p, r.data[:n]) + r.data = r.data[n:] + return n, nil +} +func check(err error) { + if err != nil { + panic(err) + } +} +func marshal(v any) []byte { b, e := json.MarshalIndent(v, "", " "); check(e); return append(b, '\n') } +func corpus(seed int64, rounds int) [][]byte { + r := rand.New(rand.NewSource(seed)) + frames := [][]byte{} + names := []string{"a", "file", "directory", "日本語", "ação"} + for i := 0; i < rounds; i++ { + q := p9.Qid{Type: 0, Vers: r.Uint32(), Path: r.Uint64()} + d := p9.Dir{Qid: q, Mode: 0600, Atime: r.Uint32(), Mtime: r.Uint32(), Length: r.Uint64(), Name: names[i%len(names)], Uid: "user", Gid: "group", Muid: "user"} + stat, e := d.Bytes() + check(e) + if i%2 == 1 { + d.Null() + stat, e = d.Bytes() + check(e) + } + data := make([]byte, []int{0, 1, 7, 127, 1024, 8192}[i%6]) + _, e = r.Read(data) + check(e) + wn := make([]string, i%17) + wq := make([]p9.Qid, i%17) + for j := range wn { + wn[j] = names[r.Intn(len(names))] + wq[j] = q + } + for typ := uint8(100); typ <= 127; typ++ { + if typ == 106 { + continue + } + f := p9.Fcall{Type: typ, Tag: uint16(r.Intn(65535)), Fid: r.Uint32(), Newfid: r.Uint32(), Afid: p9.NOFID, Uname: "user", Aname: "", Version: "9P2000", Msize: 65536, Oldtag: uint16(r.Intn(65535)), Ename: "permission denied", Qid: q, Aqid: p9.Qid{Type: p9.QTAUTH, Vers: q.Vers, Path: q.Path}, Iounit: 0, Name: d.Name, Perm: 0600, Mode: uint8(i % 4), Offset: r.Uint64(), Count: uint32(len(data)), Data: data, Wname: wn, Wqid: wq, Stat: stat} + if typ == 100 || typ == 101 { + f.Tag = p9.NOTAG + } + if typ == 114 { + f.Name = names[i%len(names)] + } + b, e := f.Bytes() + check(e) + frames = append(frames, b) + } + } + return frames +} + +type metric struct { + Implementation string `json:"implementation"` + Operations uint64 `json:"operations"` + CodecNS int64 `json:"codec_ns"` + Allocations uint64 `json:"codec_allocations"` + AllocatedBytes uint64 `json:"allocated_bytes"` + ReadCalls uint64 `json:"read_calls"` + Bytes uint64 `json:"bytes"` +} + +func measure(name string, frames [][]byte, repeats, chunk int) metric { + m := metric{Implementation: name} + var before, after runtime.MemStats + runtime.GC() + runtime.ReadMemStats(&before) + for index, frame := range frames { + start := time.Now() + for j := 0; j < repeats; j++ { + rd := reader{data: frame, chunk: chunk} + var out []byte + if name == "9fans v0.0.7" { + f, e := p9.ReadFcall(&rd) + check(e) + out, e = f.Bytes() + check(e) + } else { + f, e := other.ParseCall(&rd) + check(e) + out = f.Compose() + } + if !bytes.Equal(frame, out) { + panic(fmt.Sprintf("%s frame %d type %d differs", name, index, frame[4])) + } + m.ReadCalls += uint64(rd.calls) + m.Bytes += uint64(len(frame)) + m.Operations++ + } + m.CodecNS += time.Since(start).Nanoseconds() + } + runtime.ReadMemStats(&after) + m.Allocations = after.Mallocs - before.Mallocs + m.AllocatedBytes = after.TotalAlloc - before.TotalAlloc + return m +} +func main() { + seed := flag.Int64("seed", 4200, "deterministic valid-traffic seed") + rounds := flag.Int("rounds", 100, "27 messages per round") + repeats := flag.Int("repeats", 20, "codec repetitions") + chunk := flag.Int("chunk", 65536, "maximum read/write chunk; use 1 to fragment every byte") + session := flag.String("session-probe", "", "run live sessions against go9p") + probe := flag.String("probe", "../../zig-out/bin/cloud9-probe", "cloud9 executable") + output := flag.String("output", "results", "artifact directory") + flag.Parse() + if *rounds < 1 || *rounds > 10000 || *repeats < 1 || *repeats > 10000 || *chunk < 1 { + panic("invalid bounds") + } + frames := corpus(*seed, *rounds) + all := bytes.Join(frames, nil) + check(os.MkdirAll(*output, 0755)) + check(os.WriteFile(filepath.Join(*output, "corpus.9p"), all, 0644)) + config := map[string]any{"seed": *seed, "rounds": *rounds, "repeats": *repeats, "chunk": *chunk, "frames": len(frames), "go": runtime.Version(), "platform": runtime.GOOS + "/" + runtime.GOARCH} + check(os.WriteFile(filepath.Join(*output, "config.json"), marshal(config), 0644)) + metrics := []metric{measure("9fans v0.0.7", frames, *repeats, *chunk), measure("go9p v1.18.0", frames, *repeats, *chunk)} + var input bytes.Buffer + for _, v := range []uint32{uint32(len(frames)), uint32(*repeats), uint32(*chunk)} { + check(binary.Write(&input, binary.LittleEndian, v)) + } + input.Write(all) + command := exec.Command(*probe) + command.Stdin = &input + var stderr bytes.Buffer + command.Stderr = &stderr + out, e := command.Output() + if e != nil { + panic(fmt.Sprintf("cloud9: %v: %s", e, stderr.String())) + } + if !bytes.Equal(all, out) { + panic("cloud9 round-trip differs; replay corpus.9p with saved configuration") + } + var cloud map[string]any + check(json.Unmarshal(stderr.Bytes(), &cloud)) + report := map[string]any{"configuration": config, "correctness": "all generated base-9P2000 frames agree byte-for-byte", "cloud9": cloud, "references": metrics, "measurement_notes": []string{"Cloud9 codec timer excludes pipe I/O; Go timers include in-memory stream parsing and byte comparison. Do not treat these as equivalent throughput benchmarks.", "Go allocations use runtime.MemStats deltas on a single-process run; cloud9 codec has no allocation API.", "Cloud9 I/O counters are actual stdin/stdout syscalls; Go read counters are io.Reader calls, not syscalls.", "These are wire-conformance probes, not differential filesystem-server semantics."}} + if *session != "" { + report["session"] = sessionProbe(*session, *seed, *rounds) + } + b := marshal(report) + check(os.WriteFile(filepath.Join(*output, "report.json"), b, 0644)) + os.Stdout.Write(b) +} diff --git a/test/differential/probe.zig b/test/differential/probe.zig new file mode 100644 index 0000000..ec7c88e --- /dev/null +++ b/test/differential/probe.zig @@ -0,0 +1,59 @@ +const std = @import("std"); +const c9 = @import("cloud9"); +const libc = std.c; +var reads: u64 = 0; +var writes: u64 = 0; +var bytes_in: u64 = 0; +var bytes_out: u64 = 0; + +fn readAll(bytes: []u8, chunk: usize) !void { + var offset: usize = 0; + while (offset < bytes.len) { + reads += 1; + const n = libc.read(0, bytes[offset..].ptr, @min(chunk, bytes.len - offset)); + if (n <= 0) return error.Input; + offset += @intCast(n); + bytes_in += @intCast(n); + } +} +fn writeAll(bytes: []const u8, chunk: usize) !void { + var offset: usize = 0; + while (offset < bytes.len) { + writes += 1; + const n = libc.write(1, bytes[offset..].ptr, @min(chunk, bytes.len - offset)); + if (n <= 0) return error.Output; + offset += @intCast(n); + bytes_out += @intCast(n); + } +} +fn nowNs() u64 { + var ts: libc.timespec = undefined; + std.debug.assert(libc.clock_gettime(.MONOTONIC, &ts) == 0); + return @as(u64, @intCast(ts.sec)) * std.time.ns_per_s + @as(u64, @intCast(ts.nsec)); +} +pub fn main() !void { + var header: [12]u8 = undefined; + try readAll(&header, header.len); + const count = std.mem.readInt(u32, header[0..4], .little); + const repeats = std.mem.readInt(u32, header[4..8], .little); + const chunk = std.mem.readInt(u32, header[8..12], .little); + if (count > 1_000_000 or repeats == 0 or repeats > 10000 or chunk == 0) return error.Options; + var input: [65536]u8 = undefined; + var output: [65536]u8 = undefined; + var elapsed_ns: u64 = 0; + for (0..count) |_| { + try readAll(input[0..4], chunk); + const len = c9.frameLen(input[0..4]).?; + if (len < c9.header_len or len > input.len) return error.Frame; + try readAll(input[4..len], chunk); + const start = nowNs(); + for (0..repeats) |_| { + const decoded = try c9.decode(input[0..len]); + const encoded = try c9.encode(decoded.msg, decoded.tag, &output); + std.mem.doNotOptimizeAway(encoded); + } + elapsed_ns += nowNs() - start; + try writeAll(output[0..len], chunk); + } + std.debug.print("{{\"implementation\":\"cloud9\",\"codec_ns\":{d},\"operations\":{d},\"read_calls\":{d},\"write_calls\":{d},\"bytes_in\":{d},\"bytes_out\":{d},\"codec_allocations\":0,\"allocation_evidence\":\"no allocator or allocation calls in codec\"}}\n", .{ elapsed_ns, @as(u64, count) * repeats, reads, writes, bytes_in - header.len, bytes_out }); +} diff --git a/test/differential/session.go b/test/differential/session.go new file mode 100644 index 0000000..3fda214 --- /dev/null +++ b/test/differential/session.go @@ -0,0 +1,45 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "github.com/knusbaum/go9p" + "github.com/knusbaum/go9p/fs" + "os/exec" + "strconv" + "time" +) + +func sessionProbe(binary string, seed int64, rounds int) map[string]any { + tree, root := fs.NewFS("user", "user", 0755) + check(root.AddChild(fs.NewStaticFile(tree.NewStat("file", "user", "user", 0600), nil))) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + command := exec.CommandContext(ctx, binary, strconv.FormatInt(seed, 10), strconv.Itoa(rounds)) + input, e := command.StdinPipe() + check(e) + output, e := command.StdoutPipe() + check(e) + var stderr bytes.Buffer + command.Stderr = &stderr + check(command.Start()) + done := make(chan error, 1) + go func() { done <- go9p.ServeReadWriter(output, input, tree.Server()) }() + e = command.Wait() + input.Close() + if e != nil { + panic(fmt.Sprintf("session probe: %v: %s", e, stderr.String())) + } + select { + case <-done: + case <-time.After(time.Second): + panic("reference server did not stop") + } + var result map[string]any + check(json.Unmarshal(stderr.Bytes(), &result)) + result["server"] = "go9p v1.18.0 StaticFile" + result["checks"] = "walk/open/write/read/stat/EOF/clunk, content oracle, fragmented requests and replies" + return result +} diff --git a/test/differential/session.zig b/test/differential/session.zig new file mode 100644 index 0000000..3c5d2de --- /dev/null +++ b/test/differential/session.zig @@ -0,0 +1,82 @@ +//! Local interoperability client. The parent connects stdio to a reference server. +const std = @import("std"); +const c9 = @import("cloud9"); +const libc = std.c; +const Session = struct { + client: c9.Client, + requests: usize = 0, + bytes_read: usize = 0, + bytes_written: usize = 0, + read_calls: usize = 0, + write_calls: usize = 0, + + fn ask(s: *Session, request: c9.Client.Request) !c9.Client.Result { + const tag = try s.client.submit(request); + s.requests += 1; + while (s.client.output().len != 0) { + const output = s.client.output(); + s.write_calls += 1; + // Fragment requests across their header and body fields. + const n = libc.write(1, output.ptr, @min(output.len, 3)); + if (n <= 0) return error.Write; + s.bytes_written += @intCast(n); + s.client.wrote(@intCast(n)); + } + while (true) { + if (s.client.take()) |done| { + if (done.tag != tag) return error.Tag; + if (done.result == .fail) { + std.debug.print("remote error: {s}\n", .{done.result.fail}); + return error.Remote; + } + return done.result; + } + if (s.client.dead) return error.Protocol; + var buffer: [7]u8 = undefined; + s.read_calls += 1; + const n = libc.read(0, &buffer, buffer.len); + if (n <= 0) return error.Read; + s.bytes_read += @intCast(n); + if (s.client.push(buffer[0..@intCast(n)]) != n) return error.InputFull; + } + } +}; + +pub fn main(init: std.process.Init) !void { + const args = try init.minimal.args.toSlice(init.arena.allocator()); + if (args.len != 3) return error.Arguments; + const seed = try std.fmt.parseInt(u64, args[1], 10); + const rounds = try std.fmt.parseInt(usize, args[2], 10); + if (rounds == 0 or rounds > 10000) return error.Arguments; + var random: std.Random.DefaultPrng = .init(seed); + var input: [8192]u8 = undefined; + var output: [8192]u8 = undefined; + var session: Session = .{ .client = .init(.{ .in = &input, .out = &output }) }; + const version = (try session.ask(.{ .version = .{} })).version; + if (version.msize > input.len or !std.mem.eql(u8, version.version, "9P2000")) return error.Version; + _ = try session.ask(.{ .attach = .{ .fid = 0, .uname = "user" } }); + var expected: [4096]u8 = @splat(0); + var length: usize = 0; + for (0..rounds) |_| { + const walk = (try session.ask(.{ .walk = .{ .fid = 0, .newfid = 1, .names = &.{"file"} } })).walk; + if (walk.nwqid != 1 or walk.wqid[0].type & c9.qtdir != 0) return error.Walk; + _ = try session.ask(.{ .open = .{ .fid = 1, .mode = c9.ordwr } }); + const offset = random.random().uintLessThan(usize, 2048); + const count = random.random().uintLessThan(usize, 1024) + 1; + var data: [1024]u8 = undefined; + random.random().bytes(data[0..count]); + const written = (try session.ask(.{ .write = .{ .fid = 1, .offset = offset, .data = data[0..count] } })).write; + if (written != count) return error.WriteCount; + @memcpy(expected[offset..][0..count], data[0..count]); + length = @max(length, offset + count); + const result = (try session.ask(.{ .read = .{ .fid = 1, .offset = 0, .count = expected.len } })).read; + if (!std.mem.eql(u8, result, expected[0..length])) return error.Contents; + const stat = (try session.ask(.{ .stat = .{ .fid = 1 } })).stat; + if (stat.length != length or !std.mem.eql(u8, stat.name, "file")) return error.Stat; + const eof = (try session.ask(.{ .read = .{ .fid = 1, .offset = length, .count = 1 } })).read; + if (eof.len != 0) return error.Eof; + _ = try session.ask(.{ .clunk = .{ .fid = 1 } }); + } + _ = try session.ask(.{ .clunk = .{ .fid = 0 } }); + std.debug.print("{{\"requests\":{d},\"bytes_read\":{d},\"bytes_written\":{d},\"read_calls\":{d},\"write_calls\":{d},\"seed\":{d},\"rounds\":{d}}}\n", .{ session.requests, session.bytes_read, session.bytes_written, session.read_calls, session.write_calls, seed, rounds }); +} diff --git a/test/fuzz.zig b/test/fuzz.zig new file mode 100644 index 0000000..b7d9f21 --- /dev/null +++ b/test/fuzz.zig @@ -0,0 +1,70 @@ +//! Deterministic local robustness probes; reference implementations see valid traffic only. +const std = @import("std"); +const c9 = @import("cloud9"); +pub fn main(init: std.process.Init) !void { + const args = try init.minimal.args.toSlice(init.arena.allocator()); + const seed = if (args.len > 1) try std.fmt.parseInt(u64, args[1], 10) else 4200; + const iterations = if (args.len > 2) try std.fmt.parseInt(u32, args[2], 10) else 100000; + if (args.len > 3 or iterations > 10000000) return error.Arguments; + var prng: std.Random.DefaultPrng = .init(seed); + const random = prng.random(); + var accepted: usize = 0; + var rejected: usize = 0; + var buffer: [8192]u8 = undefined; + var encoded: [8192]u8 = undefined; + var payload: [4096]u8 = undefined; + var server_in: [8192]u8 = undefined; + var server_out: [8192]u8 = undefined; + for (0..iterations) |_| { + random.bytes(&payload); + const data = payload[0..random.uintLessThan(usize, payload.len)]; + const qid: c9.Qid = .{ .type = random.int(u8), .version = random.int(u32), .path = random.int(u64) }; + const stat: c9.Stat = .{ + .type = 0, + .dev = 0, + .qid = qid, + .mode = random.int(u32), + .atime = 0, + .mtime = 0, + .length = random.int(u64), + .name = "file", + .uid = "user", + .gid = "group", + .muid = "", + }; + const messages = [_]c9.Msg{ + .{ .tversion = .{ .msize = 8192, .version = "9P2000" } }, + .{ .twalk = .{ .fid = 0, .newfid = 1, .nwname = 16, .wname = @splat("dir") } }, + .{ .rwalk = .{ .nwqid = 16, .wqid = @splat(qid) } }, + .{ .twrite = .{ .fid = random.int(u32), .offset = random.int(u64), .data = data } }, + .{ .rread = .{ .data = data } }, + .{ .rstat = .{ .stat = stat } }, + .{ .twstat = .{ .fid = 1, .stat = stat } }, + .{ .tflush = .{ .oldtag = random.int(u16) } }, + .{ .tread = .{ .fid = 1, .offset = random.int(u64), .count = random.int(u32) } }, + }; + const msg = messages[random.uintLessThan(usize, messages.len)]; + var frame: []u8 = try c9.encode(msg, random.int(u16), &buffer); + // Leave some valid seeds unchanged; probe truncation and altered fields locally. + switch (random.uintLessThan(u8, 4)) { + 0 => {}, + 1 => frame = frame[0..random.uintLessThan(usize, frame.len)], + 2 => frame[random.uintLessThan(usize, frame.len)] ^= random.int(u8), + 3 => std.mem.writeInt(u32, frame[0..4], random.int(u32), .little), + else => unreachable, + } + if (c9.decode(frame)) |decoded| { + const output = try c9.encode(decoded.msg, decoded.tag, &encoded); + if (!std.mem.eql(u8, frame, output)) return error.RoundTrip; + accepted += 1; + } else |_| rejected += 1; + var server: c9.Server = .init(.{ .in = &server_in, .out = &server_out }); + _ = server.push(frame); + if (server.receive() catch null) |request| { + if (request.msg != .tversion) return error.BeforeVersion; + server.negotiate(request.msg.tversion.msize, request.msg.tversion.version) catch {}; + server.release(); + } + } + std.debug.print("seed={d} iterations={d} accepted={d} rejected={d}\n", .{ seed, iterations, accepted, rejected }); +} diff --git a/test/quic.zig b/test/quic.zig new file mode 100644 index 0000000..48ff839 --- /dev/null +++ b/test/quic.zig @@ -0,0 +1,268 @@ +const std = @import("std"); +const libc = std.c; +const ssl = @import("openssl"); +const Quic = @import("cloud9").Quic(ssl, "cloud9-test"); +const Listener = Quic.Listener; +const Connection = Quic.Connection; +const TestPair = struct { + listener: *Listener, + client: Connection, + server: ?Connection = null, + + fn init(listener: *Listener) !TestPair { + var p: TestPair = .{ .listener = listener, .client = try .dial(listener.address) }; + errdefer p.deinit(); + const deadline = testNow() + 3000; + while (true) { + try listener.events(); + try p.client.events(); + if (p.server == null) p.server = try listener.accept(); + const connected = try p.client.handshake(); + if (p.server) |*server| if (connected and try server.handshake()) return p; + try p.wait(deadline); + } + } + + fn wait(p: *TestPair, deadline: i64) !void { + const remaining = deadline - testNow(); + if (remaining <= 0) return error.Deadline; + var timeout: i32 = @intCast(@min(remaining, std.math.maxInt(i32))); + if (p.listener.nextDue()) |ms| timeout = @min(timeout, ms); + if (p.client.nextDue()) |ms| timeout = @min(timeout, ms); + var fds = [_]libc.pollfd{ p.listener.poll(), p.client.poll().? }; + const rc = libc.poll(&fds, fds.len, timeout); + if (rc < 0 and libc.errno(rc) != .INTR) return error.Poll; + if (testNow() >= deadline) return error.Deadline; + try p.listener.events(); + try p.client.events(); + } + + fn transfer(p: *TestPair, from_client: bool, bytes: []const u8, fragment: usize) !void { + const writer = if (from_client) &p.client else &p.server.?; + const reader = if (from_client) &p.server.? else &p.client; + var sent: usize = 0; + var received: usize = 0; + var buffer: [8192]u8 = undefined; + const deadline = testNow() + 3000; + while (received < bytes.len) { + const written = if (sent != bytes.len) try writer.write(bytes[sent..][0..@min(fragment, bytes.len - sent)]) else 0; + sent += written; + const count = try reader.read(buffer[0..@min(fragment, buffer.len)]); + if (count) |n| { + try std.testing.expect(n > 0 and n <= bytes.len - received); + try std.testing.expectEqualSlices(u8, bytes[received..][0..n], buffer[0..n]); + received += n; + } + if (testNow() >= deadline) return error.Deadline; + if (received != bytes.len and written == 0 and count == null) { + try p.wait(deadline); + } else { + try p.listener.events(); + try p.client.events(); + } + } + try std.testing.expectEqual(bytes.len, sent); + } + + fn finish(p: *TestPair) !void { + try p.client.conclude(); + try p.server.?.conclude(); + var buffer: [16]u8 = undefined; + var a = false; + var b = false; + const deadline = testNow() + 3000; + while (!a or !b) { + if (!a) if (try p.client.read(&buffer)) |n| { + try std.testing.expectEqual(@as(usize, 0), n); + a = true; + }; + if (!b) if (try p.server.?.read(&buffer)) |n| { + try std.testing.expectEqual(@as(usize, 0), n); + b = true; + }; + if (!a or !b) try p.wait(deadline); + } + } + + fn deinit(p: *TestPair) void { + if (p.server) |*server| server.deinit(); + p.client.deinit(); + } +}; + +fn testNow() i64 { + var ts: libc.timespec = undefined; + std.debug.assert(libc.clock_gettime(.MONOTONIC, &ts) == 0); + return @as(i64, @intCast(ts.sec)) * 1000 + @divFloor(@as(i64, @intCast(ts.nsec)), 1_000_000); +} + +test "QUIC fragmented 9P frames reconnect and stream EOF over IPv4 and IPv6" { + const request = "\x13\x00\x00\x00\x64\xff\xff\x00\x20\x00\x00\x06\x00" ++ "9P2000"; + const response = "\x13\x00\x00\x00\x65\xff\xff\x00\x20\x00\x00\x06\x00" ++ "9P2000"; + const read_request = "\x17\x00\x00\x00\x74\x01\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0b\x00\x00\x00"; + const read_response = "\x16\x00\x00\x00\x75\x01\x00\x0b\x00\x00\x00" ++ "hello ninep"; + for ([_][]const u8{ "127.0.0.1", "::1" }) |host| { + var listener = try Listener.init(try .parse(host, 0)); + defer listener.deinit(); + try std.testing.expect(listener.address.getPort() != 0); + if (listener.address == .ip6) { + var enabled: c_int = 0; + var len: libc.socklen_t = @sizeOf(c_int); + const v6only = if (@import("builtin").os.tag.isDarwin()) 27 else libc.IPV6.V6ONLY; + try std.testing.expectEqual(@as(c_int, 0), libc.getsockopt(listener.fd, libc.IPPROTO.IPV6, v6only, @ptrCast(&enabled), &len)); + try std.testing.expectEqual(@as(c_int, 1), enabled); + } + for (0..3) |_| { + var pair = try TestPair.init(&listener); + defer pair.deinit(); + try std.testing.expect(pair.server.?.poll() == null); + try std.testing.expect(pair.server.?.nextDue() == null); + for ([_]usize{ 1, 2, 7, 64 }) |fragment| { + try pair.transfer(true, request, fragment); + try pair.transfer(false, response, fragment); + try pair.transfer(true, read_request, fragment); + try pair.transfer(false, read_response, fragment); + } + try pair.finish(); + } + } +} + +test "QUIC backpressure retries a moved prefix while new replies are appended" { + var listener = try Listener.init(try .parse("127.0.0.1", 0)); + defer listener.deinit(); + var pair = try TestPair.init(&listener); + defer pair.deinit(); + try pair.transfer(true, "hello", 1); + const bytes: [8192]u8 = @splat(0x5a); + var total: usize = 0; + const deadline = testNow() + 3000; + while (total < 64 * 1024 * 1024) { + const written = try pair.client.write(&bytes); + total += written; + if (written == 0) break; + try pair.listener.events(); + try pair.client.events(); + if (testNow() >= deadline) return error.Deadline; + } + try std.testing.expect(total > 0 and total < 64 * 1024 * 1024); + try std.testing.expectEqual(bytes.len, pair.client.pending_write_len); + try std.testing.expectError(error.InvalidWrite, pair.client.write(bytes[0..1])); + var buffer: [8192]u8 = undefined; + var received: usize = 0; + // A pending SSL write may already have delivered a prefix before it + // reports completion. Leave that prefix for the retry check below. + while (received < total) { + if (try pair.server.?.read(buffer[0..@min(buffer.len, total - received)])) |n| { + try std.testing.expect(n > 0); + try std.testing.expect(std.mem.allEqual(u8, buffer[0..n], 0x5a)); + received += n; + } else try pair.wait(deadline); + } + try std.testing.expectEqual(total, received); + var moved: [8192 + 7]u8 = undefined; + @memcpy(moved[0..bytes.len], &bytes); + @memset(moved[bytes.len..], 0x6b); + while (true) { + const n = try pair.client.write(&moved); + if (n != 0) { + try std.testing.expectEqual(bytes.len, n); + break; + } + try pair.wait(deadline); + } + received = 0; + while (received < bytes.len) { + if (try pair.server.?.read(&buffer)) |n| { + try std.testing.expect(n > 0); + try std.testing.expect(std.mem.allEqual(u8, buffer[0..n], 0x5a)); + received += n; + } else try pair.wait(deadline); + } + try std.testing.expectEqual(bytes.len, received); + try pair.transfer(true, moved[bytes.len..], 7); + try pair.finish(); +} + +test "QUIC owner can enforce handshake and read deadlines without busy polling" { + var listener = try Listener.init(try .parse("127.0.0.1", 0)); + defer listener.deinit(); + var client = try Connection.dial(listener.address); + defer client.deinit(); + const handshake_deadline = testNow() + 100; + var turns: usize = 0; + while (testNow() < handshake_deadline) { + try std.testing.expect(!try client.handshake()); + try client.events(); + var timeout: i32 = @intCast(@max(0, handshake_deadline - testNow())); + if (client.nextDue()) |ms| timeout = @min(timeout, ms); + var fds = [_]libc.pollfd{client.poll().?}; + const rc = libc.poll(&fds, fds.len, timeout); + if (rc < 0 and libc.errno(rc) != .INTR) return error.Poll; + turns += 1; + } + try std.testing.expect(turns < 100); + var serving = try Listener.init(try .parse("127.0.0.1", 0)); + defer serving.deinit(); + var pair = try TestPair.init(&serving); + defer pair.deinit(); + try pair.transfer(true, "request", 2); + var buffer: [32]u8 = undefined; + const read_deadline = testNow() + 100; + turns = 0; + while (true) { + try std.testing.expect(try pair.client.read(&buffer) == null); + pair.wait(read_deadline) catch |err| { + try std.testing.expectEqual(error.Deadline, err); + break; + }; + turns += 1; + } + try std.testing.expect(turns < 100); +} + +test "QUIC bind failure leaves the existing listener usable" { + var listener = try Listener.init(try .parse("127.0.0.1", 0)); + defer listener.deinit(); + for (0..8) |_| try std.testing.expectError(error.Bind, Listener.init(listener.address)); + var pair = try TestPair.init(&listener); + defer pair.deinit(); + try pair.transfer(true, "still listening", 3); + try pair.transfer(false, "still serving", 2); + try pair.finish(); +} + +test "QUIC pending reports buffered bytes and EOF without UDP readiness" { + var listener = try Listener.init(try .parse("127.0.0.1", 0)); + defer listener.deinit(); + var pair = try TestPair.init(&listener); + defer pair.deinit(); + try std.testing.expect(!pair.server.?.pending()); + try std.testing.expectEqual(@as(usize, 3), try pair.client.write("abc")); + const deadline = testNow() + 3000; + while (!pair.server.?.pending()) try pair.wait(deadline); + var buffer: [3]u8 = undefined; + try std.testing.expectEqual(@as(?usize, 1), try pair.server.?.read(buffer[0..1])); + try std.testing.expectEqual(@as(u8, 'a'), buffer[0]); + try listener.events(); + try pair.client.events(); + try std.testing.expect(pair.server.?.pending()); + try std.testing.expectEqual(@as(?usize, 2), try pair.server.?.read(buffer[1..])); + try std.testing.expectEqualStrings("abc", &buffer); + try std.testing.expect(!pair.server.?.pending()); + try pair.client.conclude(); + while (!pair.server.?.pending()) try pair.wait(deadline); + try std.testing.expectEqual(@as(?usize, 0), try pair.server.?.read(&buffer)); +} + +test "QUIC moved listener retains its in-memory identity" { + var original = try Listener.init(try .parse("127.0.0.1", 0)); + var listener = original; + original = undefined; + defer listener.deinit(); + var pair = try TestPair.init(&listener); + defer pair.deinit(); + try pair.transfer(true, "moved listener", 2); + try pair.transfer(false, "same identity", 3); + try pair.finish(); +} diff --git a/test/transport.zig b/test/transport.zig new file mode 100644 index 0000000..3d43ddb --- /dev/null +++ b/test/transport.zig @@ -0,0 +1,82 @@ +const std = @import("std"); +const c9 = @import("cloud9"); +const t = c9.transport; +const testing = std.testing; + +test "standard readers and writers preserve frame boundaries" { + var bytes: [128]u8 = undefined; + const frame = try c9.encode(.{ .tversion = .{ .msize = 4096, .version = "9P2000" } }, c9.notag, &bytes); + var source: testing.Reader = .init(&.{}, &.{.{ .buffer = frame }}); + source.artificial_limit = .limited(1); + var target: [128]u8 = undefined; + const got = try t.readFrame(&source.interface, &target, 4096); + try testing.expectEqualSlices(u8, frame, got); + var output: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&output); + try t.writeFrame(&writer, got, 4096); + try testing.expectEqualSlices(u8, frame, writer.buffered()); +} + +test "POSIX TCP and Unix streams handle retry and EOF" { + var dir = testing.tmpDir(.{}); + defer dir.cleanup(); + const io = testing.io; + var path_buffer: [std.fs.max_path_bytes]u8 = undefined; + const parent_len = try dir.dir.realPath(io, &path_buffer); + const parent = path_buffer[0..parent_len]; + var unix_buffer: [t.sun_path_len]u8 = undefined; + const unix = try std.fmt.bufPrintSentinel(&unix_buffer, "{s}/9p", .{parent}, 0); + for ([_]t.Address{ .{ .tcp = .{ .ip4 = .loopback(0) } }, .{ .unix = unix } }) |address| { + const listener = try t.listenFd(address, 4); + defer t.close(listener); + var destination = address; + if (address == .tcp) { + var actual: std.c.sockaddr.in = undefined; + var len: std.c.socklen_t = @sizeOf(@TypeOf(actual)); + try testing.expectEqual(@as(c_int, 0), std.c.getsockname(listener, @ptrCast(&actual), &len)); + destination.tcp.setPort(std.mem.bigToNative(u16, actual.port)); + } + const client = try t.connectFd(destination, t.nowMs() + 1000); + defer t.close(client); + const server = (try t.acceptFd(listener, address == .tcp)) orelse return error.NoConnection; + var buffer: [32]u8 = undefined; + try testing.expectEqual(@as(?usize, null), try t.read(server, &buffer)); + try testing.expectEqual(@as(?usize, 3), try t.write(client, "abc")); + try t.wait(server, @intCast(std.c.POLL.IN), t.nowMs() + 1000); + try testing.expectEqual(@as(?usize, 3), try t.read(server, &buffer)); + try testing.expectEqualStrings("abc", buffer[0..3]); + t.close(server); + try t.wait(client, @intCast(std.c.POLL.IN), t.nowMs() + 1000); + try testing.expectEqual(@as(?usize, 0), try t.read(client, &buffer)); + } +} + +test "std.Io TCP and Unix listener and client adapters" { + var dir = testing.tmpDir(.{}); + defer dir.cleanup(); + const io = testing.io; + var path_buffer: [std.fs.max_path_bytes]u8 = undefined; + const parent_len = try dir.dir.realPath(io, &path_buffer); + const parent = path_buffer[0..parent_len]; + var unix_buffer: [t.sun_path_len]u8 = undefined; + const unix = try std.fmt.bufPrintSentinel(&unix_buffer, "{s}/9p", .{parent}, 0); + for ([_]t.Address{ .{ .tcp = .{ .ip4 = .loopback(0) } }, .{ .unix = unix } }) |address| { + var listener = try t.listen(io, address, 4); + defer listener.deinit(io); + const destination: t.Address = if (address == .tcp) .{ .tcp = listener.socket.address } else address; + const client = try t.connect(io, destination); + defer client.close(io); + const server = try listener.accept(io); + defer server.close(io); + var output: [128]u8 = undefined; + var writer = client.writer(io, &output); + var bytes: [128]u8 = undefined; + const frame = try c9.encode(.rflush, 1, &bytes); + try t.writeFrame(&writer.interface, frame, 4096); + try writer.interface.flush(); + var input: [128]u8 = undefined; + var reader = server.reader(io, &input); + var target: [128]u8 = undefined; + try testing.expectEqualSlices(u8, frame, try t.readFrame(&reader.interface, &target, 4096)); + } +} |
