diff --git a/internal/parser/cedar_marshal.go b/internal/parser/cedar_marshal.go index d77a2b45..5b61841d 100644 --- a/internal/parser/cedar_marshal.go +++ b/internal/parser/cedar_marshal.go @@ -3,6 +3,7 @@ package parser import ( "bytes" "fmt" + "io" "github.com/cedar-policy/cedar-go/internal/consts" "github.com/cedar-policy/cedar-go/internal/extensions" @@ -26,6 +27,28 @@ func (p *Policy) MarshalCedar(buf *bytes.Buffer) { buf.WriteRune(';') } +type Encoder struct { + w io.Writer +} + +func NewEncoder(w io.Writer) *Encoder { + return &Encoder{w: w} +} + +func (e *Encoder) Encode(p *Policy) error { + var buf bytes.Buffer + p.MarshalCedar(&buf) + + buf.WriteByte('\n') + + _, err := e.w.Write(buf.Bytes()) + if err != nil { + return err + } + + return nil +} + // scopeToNode is copied in from eval, with the expectation that // eval will not be using it in the future. func scopeToNode(varNode ast.NodeTypeVariable, in ast.IsScopeNode) ast.Node { diff --git a/internal/parser/cedar_marshal_test.go b/internal/parser/cedar_marshal_test.go new file mode 100644 index 00000000..72a0945a --- /dev/null +++ b/internal/parser/cedar_marshal_test.go @@ -0,0 +1,55 @@ +package parser_test + +import ( + "bytes" + "io" + "testing" + + "github.com/cedar-policy/cedar-go/internal/parser" + "github.com/cedar-policy/cedar-go/internal/testutil" + "github.com/cedar-policy/cedar-go/x/exp/ast" +) + +func TestEncoder(t *testing.T) { + var buf bytes.Buffer + + encoder := parser.NewEncoder(&buf) + + policy := ast.Permit(). + PrincipalEq(johnny). + ActionEq(sow). + ResourceEq(apple). + When(ast.Boolean(true)). + Unless(ast.Boolean(false)) + + err := encoder.Encode((*parser.Policy)(policy)) + testutil.OK(t, err) + + const expected = `permit ( + principal == User::"johnny", + action == Action::"sow", + resource == Crop::"apple" +) +when { true } +unless { false }; +` + + testutil.Equals(t, buf.String(), expected) +} + +func TestEncoderError(t *testing.T) { + _, w := io.Pipe() + _ = w.Close() + + encoder := parser.NewEncoder(w) + + policy := ast.Permit(). + PrincipalEq(johnny). + ActionEq(sow). + ResourceEq(apple). + When(ast.Boolean(true)). + Unless(ast.Boolean(false)) + + err := encoder.Encode((*parser.Policy)(policy)) + testutil.Error(t, err) +} diff --git a/internal/parser/cedar_tokenize.go b/internal/parser/cedar_tokenize.go index c28d6ffa..1492e8db 100644 --- a/internal/parser/cedar_tokenize.go +++ b/internal/parser/cedar_tokenize.go @@ -74,9 +74,13 @@ func (t Token) intValue() (int64, error) { } func Tokenize(src []byte) ([]Token, error) { + return TokenizeReader(bytes.NewBuffer(src)) +} + +func TokenizeReader(r io.Reader) ([]Token, error) { var res []Token var s scanner - s.Init(bytes.NewBuffer(src)) + s.Init(r) for tok := s.nextToken(); s.err == nil && tok.Type != TokenEOF; tok = s.nextToken() { res = append(res, tok) } diff --git a/internal/parser/cedar_unmarshal.go b/internal/parser/cedar_unmarshal.go index be650d57..f36af585 100644 --- a/internal/parser/cedar_unmarshal.go +++ b/internal/parser/cedar_unmarshal.go @@ -2,8 +2,10 @@ package parser import ( "fmt" + "io" "strconv" "strings" + "sync" "github.com/cedar-policy/cedar-go/internal/consts" "github.com/cedar-policy/cedar-go/internal/extensions" @@ -33,6 +35,38 @@ func (p *PolicySlice) UnmarshalCedar(b []byte) error { return nil } +type Decoder struct { + createParser func() (*parser, error) +} + +func NewDecoder(r io.Reader) *Decoder { + return &Decoder{ + createParser: sync.OnceValues(func() (*parser, error) { + tokens, err := TokenizeReader(r) + if err != nil { + return nil, err + } + + p := newParser(tokens) + + return &p, nil + }), + } +} + +func (d *Decoder) Decode(p *Policy) error { + parser, err := d.createParser() + if err != nil { + return err + } + + if parser.peek().isEOF() { + return io.EOF + } + + return p.fromCedar(parser) +} + func (p *Policy) UnmarshalCedar(b []byte) error { tokens, err := Tokenize(b) if err != nil { diff --git a/internal/parser/cedar_unmarshal_test.go b/internal/parser/cedar_unmarshal_test.go index 1b0aeb93..dc92b8d7 100644 --- a/internal/parser/cedar_unmarshal_test.go +++ b/internal/parser/cedar_unmarshal_test.go @@ -2,6 +2,8 @@ package parser_test import ( "bytes" + "fmt" + "io" "strings" "testing" @@ -529,6 +531,59 @@ func TestParsePolicySetErrors(t *testing.T) { } } +func TestDecoder(t *testing.T) { + policyStr := `permit ( + principal, + action, + resource + ); + forbid ( + principal, + action, + resource + ); + +` + + decoder := parser.NewDecoder(strings.NewReader(policyStr)) + + var actualPolicy0 parser.Policy + testutil.OK(t, decoder.Decode(&actualPolicy0)) + + expectedPolicy0 := ast.Permit() + expectedPolicy0.Position = ast.Position{Offset: 0, Line: 1, Column: 1} + testutil.Equals(t, &actualPolicy0, (*parser.Policy)(expectedPolicy0)) + + var actualPolicy1 parser.Policy + testutil.OK(t, decoder.Decode(&actualPolicy1)) + + expectedPolicy1 := ast.Forbid() + expectedPolicy1.Position = ast.Position{Offset: 48, Line: 6, Column: 2} + testutil.Equals(t, &actualPolicy1, (*parser.Policy)(expectedPolicy1)) + + testutil.ErrorIs(t, decoder.Decode(nil), io.EOF) +} + +func TestDecoderErrors(t *testing.T) { + t.Parallel() + tests := []string{ + `permit("everything"); + +`, + "okay\x00not okay", + } + + for i, tt := range tests { + t.Run(fmt.Sprintf("%02d", i), func(t *testing.T) { + t.Parallel() + decoder := parser.NewDecoder(strings.NewReader(tt)) + + testutil.Error(t, decoder.Decode(nil)) + testutil.Error(t, decoder.Decode(nil)) + }) + } +} + func TestParsePolicySet(t *testing.T) { t.Parallel() t.Run("single policy", func(t *testing.T) { diff --git a/stream.go b/stream.go new file mode 100644 index 00000000..6fdd9c03 --- /dev/null +++ b/stream.go @@ -0,0 +1,53 @@ +package cedar + +import ( + "io" + + "github.com/cedar-policy/cedar-go/ast" + "github.com/cedar-policy/cedar-go/internal/parser" +) + +// Encoder encodes [Policy] statements in the human-readable format specified by the [Cedar documentation] +// and writes them to an [io.Writer]. +// +// [Cedar documentation]: https://docs.cedarpolicy.com/policies/syntax-grammar.html +type Encoder struct { + enc *parser.Encoder +} + +// NewEncoder returns a new [Encoder]. +func NewEncoder(w io.Writer) *Encoder { + return &Encoder{enc: parser.NewEncoder(w)} +} + +// Encode encodes and writes a single [Policy] statement to the underlying [io.Writer]. +func (e *Encoder) Encode(p *Policy) error { + return e.enc.Encode((*parser.Policy)(p.AST())) +} + +// Decoder reads, parses and compiles [Policy] statements in the human-readable format specified by the [Cedar documentation] +// from an [io.Reader]. +// +// [Cedar documentation]: https://docs.cedarpolicy.com/policies/syntax-grammar.html +type Decoder struct { + dec *parser.Decoder +} + +// NewDecoder returns a new [Decoder]. +func NewDecoder(r io.Reader) *Decoder { + return &Decoder{dec: parser.NewDecoder(r)} +} + +// Decode parses and compiles a single [Policy] statement from the underlying [io.Reader]. +func (e *Decoder) Decode(p *Policy) error { + var parserPolicy parser.Policy + + err := e.dec.Decode(&parserPolicy) + if err != nil { + return err + } + + *p = *NewPolicyFromAST((*ast.Policy)(&parserPolicy)) + + return nil +} diff --git a/stream_test.go b/stream_test.go new file mode 100644 index 00000000..4f510a40 --- /dev/null +++ b/stream_test.go @@ -0,0 +1,135 @@ +package cedar_test + +import ( + "bytes" + "io" + "strings" + "testing" + + "github.com/cedar-policy/cedar-go" + "github.com/cedar-policy/cedar-go/ast" + "github.com/cedar-policy/cedar-go/internal/testutil" + internalast "github.com/cedar-policy/cedar-go/x/exp/ast" +) + +func newPolicy() *cedar.Policy { + policyJSON := []byte(`{ + "effect": "permit", + "principal": { + "op": "==", + "entity": { "type": "User", "id": "bob" } + }, + "action": { + "op": "==", + "entity": { "type": "Action", "id": "view" } + }, + "resource": { + "op": "in", + "entity": { "type": "Folder", "id": "abc" } + } +}`) + + var policy cedar.Policy + + _ = policy.UnmarshalJSON(policyJSON) + + return &policy +} + +func TestEncoder(t *testing.T) { + policy0 := newPolicy() + policy1 := newPolicy() + + var buf bytes.Buffer + + encoder := cedar.NewEncoder(&buf) + + err := encoder.Encode(policy0) + testutil.OK(t, err) + + err = encoder.Encode(policy1) + testutil.OK(t, err) + + const expected = `permit ( + principal == User::"bob", + action == Action::"view", + resource in Folder::"abc" +); +permit ( + principal == User::"bob", + action == Action::"view", + resource in Folder::"abc" +); +` + + testutil.Equals(t, expected, buf.String()) +} + +func TestEncoderError(t *testing.T) { + policy := newPolicy() + + _, w := io.Pipe() + _ = w.Close() + + encoder := cedar.NewEncoder(w) + + err := encoder.Encode(policy) + testutil.Error(t, err) +} + +func TestDecoder(t *testing.T) { + policyStr := `permit ( + principal, + action, + resource + ); + forbid ( + principal, + action, + resource + ); + +` + + decoder := cedar.NewDecoder(strings.NewReader(policyStr)) + + var actualPolicy0 cedar.Policy + testutil.OK(t, decoder.Decode(&actualPolicy0)) + + astPolicy0 := internalast.Permit() + astPolicy0.Position = internalast.Position{Offset: 0, Line: 1, Column: 1} + expectedPolicy0 := cedar.NewPolicyFromAST((*ast.Policy)(astPolicy0)) + testutil.Equals(t, &actualPolicy0, expectedPolicy0) + + var actualPolicy1 cedar.Policy + testutil.OK(t, decoder.Decode(&actualPolicy1)) + + astPolicy1 := internalast.Forbid() + astPolicy1.Position = internalast.Position{Offset: 48, Line: 6, Column: 2} + expectedPolicy1 := cedar.NewPolicyFromAST((*ast.Policy)(astPolicy1)) + testutil.Equals(t, &actualPolicy1, expectedPolicy1) + + testutil.ErrorIs(t, decoder.Decode(nil), io.EOF) +} + +func TestEncoderDecoder(t *testing.T) { + policy0 := newPolicy() + policy1 := newPolicy() + + var buf bytes.Buffer + + encoder := cedar.NewEncoder(&buf) + decoder := cedar.NewDecoder(&buf) + + err := encoder.Encode(policy0) + testutil.OK(t, err) + + err = encoder.Encode(policy1) + testutil.OK(t, err) + + var decodedPolicy0 cedar.Policy + testutil.OK(t, decoder.Decode(&decodedPolicy0)) + + var decodedPolicy1 cedar.Policy + testutil.OK(t, decoder.Decode(&decodedPolicy1)) +}