diff --git a/.drone.yml b/.drone.yml index 4cafc07..6409064 100644 --- a/.drone.yml +++ b/.drone.yml @@ -14,6 +14,13 @@ steps: depends_on: - go build + - name: unit tests + image: golang:1.26 + commands: + - go test -timeout 60s -v ./... + depends_on: + - go build + trigger: action: exclude: diff --git a/alpns.go b/alpns.go index ebff344..39c239d 100644 --- a/alpns.go +++ b/alpns.go @@ -1,7 +1,9 @@ package iroh_ffi +// Application-Layer Protocol Negotiation type Alpn []byte +// List of ALPNs func Alpns(alpns ...Alpn) [][]byte { var a [][]byte diff --git a/endpoint.go b/endpoint.go index 745354e..7e25fec 100644 --- a/endpoint.go +++ b/endpoint.go @@ -6,6 +6,7 @@ import ( "golang.org/x/sync/errgroup" ) +// Accept implements the receiving end of a endpoint. func (e *Endpoint) Accept(ctx context.Context, a Accepter) error { g, gCtx := errgroup.WithContext(ctx) @@ -39,6 +40,7 @@ func (e *Endpoint) Accept(ctx context.Context, a Accepter) error { return g.Wait() } +// Open implements the remote connection of an endpoint. func (e *Endpoint) Open(ctx context.Context, addr *EndpointAddr, alpn Alpn, o Opener) error { conn, err := e.Connect(addr, alpn) if err != nil { diff --git a/endpoint/endpoint_test.go b/endpoint/endpoint_test.go new file mode 100644 index 0000000..e28b727 --- /dev/null +++ b/endpoint/endpoint_test.go @@ -0,0 +1,24 @@ +package endpoint_test + +import ( + "testing" + + iroh "git.coopcloud.tech/decentral1se/iroh-go" + "git.coopcloud.tech/decentral1se/iroh-go/endpoint" +) + +func TestEndpoint(t *testing.T) { + alpn := iroh.Alpn("foo/0") + + e, err := endpoint.New( + endpoint.WithAlpns(alpn), + endpoint.WithApplyMinimal(), + ) + if err != nil { + t.Errorf("want endpoint creation, got: %s", iroh.As(err)) + } + + if err := e.Close(); err != nil { + t.Errorf("want endpoint close, got: %s", iroh.As(err)) + } +} diff --git a/endpoint_test.go b/endpoint_test.go new file mode 100644 index 0000000..8c21fa5 --- /dev/null +++ b/endpoint_test.go @@ -0,0 +1,127 @@ +package iroh_ffi_test + +import ( + "context" + "sync" + "testing" + "time" + + iroh "git.coopcloud.tech/decentral1se/iroh-go" + "git.coopcloud.tech/decentral1se/iroh-go/endpoint" + "github.com/magiconair/properties/assert" +) + +type TestAcceptOpener struct { + ExpectedOutput string + ReceivedOutput string +} + +func (t *TestAcceptOpener) Open(ctx context.Context, conn *iroh.Connection) error { + stream, err := conn.OpenBi() + if err != nil { + return err + } + + if err = stream.Ready(); err != nil { + return err + } + + recv := stream.Recv() + + frame, err := recv.ReadToEnd(1000) + if err != nil { + return err + } + + t.ReceivedOutput += string(frame) + + if err := conn.Close(0, []byte("eof")); err != nil { + return err + } + + return nil +} + +func (t *TestAcceptOpener) Accept(ctx context.Context, conn *iroh.Connection) error { + stream, err := conn.AcceptBi() + if err := iroh.As(err); err != nil { + return err + } + + send := stream.Send() + + if err := send.WriteAll([]byte(t.ExpectedOutput)); err != nil { + return err + } + + if err := send.Finish(); err != nil { + return err + } + + conn.Closed() + + return nil +} + +func TestEndpoint(t *testing.T) { + alpn := iroh.Alpn("iroh-go-endpoint-test/0") + + e1, err := endpoint.New( + endpoint.WithAlpns(alpn), + endpoint.WithApplyN0(), + ) + if err := iroh.As(err); err != nil { + t.Errorf("want endpoint create, got: %s", err) + } + + e1.Online() + + var wg sync.WaitGroup + c := make(chan struct{}) + + tao := &TestAcceptOpener{ExpectedOutput: "ping"} + + ctx := context.Background() + + wg.Go(func() { + if err := e1.Accept(ctx, tao); err != nil { + t.Errorf("want endpoint accept, got: %s", iroh.As(err)) + } + if err := e1.Close(); err != nil { + t.Errorf("want endpoint close, got: %s", iroh.As(err)) + } + }) + + e2, err := endpoint.New( + endpoint.WithAlpns(alpn), + endpoint.WithApplyN0(), + ) + if err := iroh.As(err); err != nil { + t.Errorf("want endpoint create, got: %s", err) + } + + e2.Online() + + wg.Go(func() { + if err := e2.Open(ctx, e1.Addr(), alpn, tao); err != nil { + t.Errorf("want endpoint open, got: %s", iroh.As(err)) + } + if err := e2.Close(); err != nil { + t.Errorf("want endpoint close, got: %s", iroh.As(err)) + } + }) + + go func() { + wg.Wait() + c <- struct{}{} + }() + + timeout := time.Duration(10) * time.Second + + select { + case <-c: + assert.Equal(t, tao.ReceivedOutput, tao.ExpectedOutput) + case <-time.After(timeout): + t.Errorf("want graceful close, got: timeout of 10 seconds") + } +} diff --git a/examples/simple-pipe/simple-pipe.go b/examples/simple-pipe/simple-pipe.go index 5929b4f..cf60dc9 100644 --- a/examples/simple-pipe/simple-pipe.go +++ b/examples/simple-pipe/simple-pipe.go @@ -77,9 +77,11 @@ func send(e *iroh.Endpoint, id string) { } // mark send stream as done - p.Finish() - exit(conn) + if err := p.Finish(); err != nil { + panic(iroh.As(err)) + } + exit(conn) } func exit(conn *iroh.Connection) { diff --git a/go.mod b/go.mod index cf3e744..5aac5e4 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,7 @@ module git.coopcloud.tech/decentral1se/iroh-go go 1.26.1 -require golang.org/x/sync v0.24.0 +require ( + github.com/magiconair/properties v1.18.12 + golang.org/x/sync v0.24.0 +) diff --git a/go.sum b/go.sum index 76e6342..30d2e10 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,4 @@ +github.com/magiconair/properties v1.18.12 h1:sT9zQpvTB3B4gzrX0tmZNTEaGyg8Zw55MFYRE32Mr9I= +github.com/magiconair/properties v1.18.12/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= diff --git a/interfaces.go b/interfaces.go index cc6f57c..1808758 100644 --- a/interfaces.go +++ b/interfaces.go @@ -2,14 +2,19 @@ package iroh_ffi import "context" +// Accepter implements the incoming side of a BiStream. This is still +// peer-to-peer and the incoming side can send and receive. type Accepter interface { Accept(context.Context, *Connection) error } +// Opener implements the connecting side of a BiStream. This is still +// peer-to-peer and the connecting side can send and receive. type Opener interface { Open(context.Context, *Connection) error } +// AcceptOpener combines both the "incoming" and "outgoing" interfaces. type AcceptOpener interface { Accepter Opener diff --git a/logger.go b/logger.go index 52e1e49..de65cfe 100644 --- a/logger.go +++ b/logger.go @@ -5,4 +5,6 @@ import ( "os" ) +// Log is the library-wide internal logger. It can be configured using the +// `endpoint.New(...)` options pattern. See `WithDebug` for more. var Log = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{})) diff --git a/pipe.go b/pipe.go index 5b4249a..03ce231 100644 --- a/pipe.go +++ b/pipe.go @@ -5,14 +5,16 @@ import ( "io" ) +// Pipe is a BiStream which supports the Reader/Writer interface. type Pipe struct { stream *BiStream recv *RecvStream send *SendStream totalBytesRead int - finished bool + finished bool } +// NewPipe creates a new Pipe. func NewPipe(stream *BiStream) *Pipe { return &Pipe{ stream: stream, @@ -21,6 +23,7 @@ func NewPipe(stream *BiStream) *Pipe { } } +// Ready ensures the other side of the Pipe knows that it is open. func (p *Pipe) Ready() error { if err := p.stream.Ready(); err != nil { return err @@ -28,6 +31,7 @@ func (p *Pipe) Ready() error { return nil } +// Read implements the Reader interface. func (p *Pipe) Read(buf []byte) (int, error) { if len(buf) == 0 { return 0, nil @@ -56,6 +60,7 @@ func (p *Pipe) Read(buf []byte) (int, error) { return len(retbuf), err } +// Write implements the Writer interface. func (p *Pipe) Write(buf []byte) (int, error) { if p.finished { return 0, errors.New("Pipe.Write: Write called after Finish()") @@ -71,6 +76,9 @@ func (p *Pipe) Write(buf []byte) (int, error) { return n, err } +// Finish called on the send stream signals that we will not send anything +// further, which makes the receive side of the Pipe on the other end +// terminate. func (p *Pipe) Finish() error { p.finished = true return p.send.Finish() diff --git a/pipe_test.go b/pipe_test.go new file mode 100644 index 0000000..bd4af25 --- /dev/null +++ b/pipe_test.go @@ -0,0 +1,130 @@ +package iroh_ffi_test + +import ( + "bytes" + "context" + "io" + "sync" + "testing" + "time" + + iroh "git.coopcloud.tech/decentral1se/iroh-go" + "git.coopcloud.tech/decentral1se/iroh-go/endpoint" + "github.com/magiconair/properties/assert" +) + +type TestAcceptOpenerPipe struct { + ExpectedOutput bytes.Buffer + ReceivedOutput bytes.Buffer +} + +func (t *TestAcceptOpenerPipe) Open(ctx context.Context, conn *iroh.Connection) error { + stream, err := conn.OpenBi() + if err != nil { + return err + } + + p := iroh.NewPipe(stream) + + if err := p.Ready(); err != nil { + return err + } + + _, err = io.Copy(p, &t.ExpectedOutput) + if err = iroh.As(err); err != nil { + return err + } + + if err := p.Finish(); err != nil { + return err + } + + conn.Closed() + + if err := conn.Close(0, []byte("eof")); err != nil { + return err + } + + return nil +} + +func (t *TestAcceptOpenerPipe) Accept(ctx context.Context, conn *iroh.Connection) error { + stream, err := conn.AcceptBi() + if err := iroh.As(err); err != nil { + return err + } + + p := iroh.NewPipe(stream) + + _, err = io.Copy(p, &t.ReceivedOutput) + if err = iroh.As(err); err != nil { + return err + } + + return nil +} + +func TestPipe(t *testing.T) { + alpn := iroh.Alpn("iroh-go-pipe-test/0") + + e1, err := endpoint.New( + endpoint.WithAlpns(alpn), + endpoint.WithApplyN0(), + ) + if err := iroh.As(err); err != nil { + panic(err) + } + + e1.Online() + + var wg sync.WaitGroup + c := make(chan struct{}) + + tao := &TestAcceptOpenerPipe{ + ExpectedOutput: *bytes.NewBufferString("ping"), + } + + ctx := context.Background() + + wg.Go(func() { + if err := e1.Accept(ctx, tao); err != nil { + t.Errorf("want endpoint accept, got: %s", iroh.As(err)) + } + if err := e1.Close(); err != nil { + t.Errorf("want endpoint close, got: %s", iroh.As(err)) + } + }) + + e2, err := endpoint.New( + endpoint.WithAlpns(alpn), + endpoint.WithApplyN0(), + ) + if err := iroh.As(err); err != nil { + panic(err) + } + + e2.Online() + + wg.Go(func() { + if err := e2.Open(ctx, e1.Addr(), alpn, tao); err != nil { + t.Errorf("want endpoint open, got: %s", iroh.As(err)) + } + if err := e2.Close(); err != nil { + t.Errorf("want endpoint close, got: %s", iroh.As(err)) + } + }) + + go func() { + wg.Wait() + c <- struct{}{} + }() + + timeout := time.Duration(10) * time.Second + + select { + case <-c: + assert.Equal(t, tao.ReceivedOutput.String(), tao.ExpectedOutput.String()) + case <-time.After(timeout): + t.Errorf("want graceful close, got: timeout of 10 seconds") + } +} diff --git a/stream.go b/stream.go index 31628ae..9dc032c 100644 --- a/stream.go +++ b/stream.go @@ -1,5 +1,6 @@ package iroh_ffi +// Ready ensures the other side of the BiStream knows that the stream is open. func (s *BiStream) Ready() error { if err := s.Send().WriteAll([]byte{1}); err != nil { return err diff --git a/util/util.go b/util/util.go index 6b7e2c6..4f9a864 100644 --- a/util/util.go +++ b/util/util.go @@ -4,8 +4,9 @@ import ( "crypto/sha256" ) +// SecretBytesFromString creates secret bytes from a string. func SecretBytesFromString(str string) []byte { - // TODO (2026-11-09): securely clear all arrays to the extent possible - digest := sha256.Sum256([]byte(str)) - return digest[:] // copy out bytes from strict [32]byte type to []byte used by everything else + iroh + // TODO (2026-11-09): securely clear all arrays to the extent possible + digest := sha256.Sum256([]byte(str)) + return digest[:] // copy out bytes from strict [32]byte type to []byte used by everything else + iroh } diff --git a/util/util_test.go b/util/util_test.go new file mode 100644 index 0000000..59ba50c --- /dev/null +++ b/util/util_test.go @@ -0,0 +1,26 @@ +package util_test + +import ( + "testing" + + iroh "git.coopcloud.tech/decentral1se/iroh-go" + "git.coopcloud.tech/decentral1se/iroh-go/endpoint" + "git.coopcloud.tech/decentral1se/iroh-go/util" +) + +func TestSecretBytesFromString(t *testing.T) { + alpn := iroh.Alpn("iroh-go-util-test/0") + + e, err := endpoint.New( + endpoint.WithAlpns(alpn), + endpoint.WithApplyN0(), + endpoint.WithSecretKey(util.SecretBytesFromString("hello-secret-bytes")), + ) + if err := iroh.As(err); err != nil { + t.Errorf("want endpoint create, got: %s", err) + } + + if err := e.Close(); err != nil { + t.Errorf("want endpoint close, got: %s", iroh.As(err)) + } +}