opa-golib-python-bindings 0.4.0__tar.gz → 0.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (18) hide show
  1. {opa_golib_python_bindings-0.4.0 → opa_golib_python_bindings-0.4.2}/PKG-INFO +24 -1
  2. {opa_golib_python_bindings-0.4.0 → opa_golib_python_bindings-0.4.2}/README.md +23 -0
  3. {opa_golib_python_bindings-0.4.0 → opa_golib_python_bindings-0.4.2}/go/bridge.go +54 -17
  4. {opa_golib_python_bindings-0.4.0 → opa_golib_python_bindings-0.4.2}/go/builtins.go +6 -2
  5. {opa_golib_python_bindings-0.4.0 → opa_golib_python_bindings-0.4.2}/go/compile.go +50 -12
  6. {opa_golib_python_bindings-0.4.0 → opa_golib_python_bindings-0.4.2}/pyproject.toml +1 -1
  7. {opa_golib_python_bindings-0.4.0 → opa_golib_python_bindings-0.4.2}/src/opa_bindings/_native.py +4 -3
  8. {opa_golib_python_bindings-0.4.0 → opa_golib_python_bindings-0.4.2}/src/opa_bindings/engine.py +88 -37
  9. {opa_golib_python_bindings-0.4.0 → opa_golib_python_bindings-0.4.2}/.gitignore +0 -0
  10. {opa_golib_python_bindings-0.4.0 → opa_golib_python_bindings-0.4.2}/LICENSE +0 -0
  11. {opa_golib_python_bindings-0.4.0 → opa_golib_python_bindings-0.4.2}/Makefile +0 -0
  12. {opa_golib_python_bindings-0.4.0 → opa_golib_python_bindings-0.4.2}/go/bridge_call.c +0 -0
  13. {opa_golib_python_bindings-0.4.0 → opa_golib_python_bindings-0.4.2}/go/go.mod +0 -0
  14. {opa_golib_python_bindings-0.4.0 → opa_golib_python_bindings-0.4.2}/go/go.sum +0 -0
  15. {opa_golib_python_bindings-0.4.0 → opa_golib_python_bindings-0.4.2}/go/merge.go +0 -0
  16. {opa_golib_python_bindings-0.4.0 → opa_golib_python_bindings-0.4.2}/go/trace.go +0 -0
  17. {opa_golib_python_bindings-0.4.0 → opa_golib_python_bindings-0.4.2}/hatch_build.py +0 -0
  18. {opa_golib_python_bindings-0.4.0 → opa_golib_python_bindings-0.4.2}/src/opa_bindings/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: opa-golib-python-bindings
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Python bindings for the OPA (Open Policy Agent) Rego engine via a Go c-shared library
5
5
  Project-URL: Homepage, https://github.com/phi1010/opa-golib-python-bindings
6
6
  Project-URL: Repository, https://github.com/phi1010/opa-golib-python-bindings
@@ -63,6 +63,10 @@ Notes:
63
63
 
64
64
  - `add_data` deep-merges objects; identical values coexist, conflicting values raise
65
65
  `OpaError(code="merge_conflict")` naming the conflicting path.
66
+ - A Go-side panic in any bridge call surfaces as `OpaError(code="panic")`
67
+ instead of killing the process; the engine stays usable afterwards. (The
68
+ bridge also validates `compile_filters(mappings=...)` values are strings
69
+ and rejects malformed shapes with `OpaError(code="invalid_json")`.)
66
70
  - `register_function` infers arity from the callable's signature. A `*args` function is
67
71
  variadic and is called from Rego with a single array argument: `many(["a", "b"])`
68
72
  (OPA does not support variadic builtins with return values).
@@ -131,3 +135,22 @@ Notes:
131
135
  - Rego `print(...)` output is captured per evaluation: set `engine.print_handler`
132
136
  to a `callable(message, location)` to receive it (default: written to stderr);
133
137
  `engine.last_prints` holds the `(message, location)` pairs of the last eval.
138
+ - **Concurrency:** an engine may be shared across threads — evals are
139
+ serialized by an internal re-entrant lock (builtins may evaluate on their
140
+ own engine). Per-eval state (`last_trace`, `last_coverage`, `last_prints`)
141
+ reflects the most recently *completed* eval, so with a shared engine prefer
142
+ return values over `last_*` attributes, or use one engine per thread.
143
+ - **Resource limits / DoS:** there are no built-in caps. Policy/data sizes,
144
+ the number of engines (call `close()`; `__del__` is best-effort under GC),
145
+ and the set of distinct query strings (each is cached as a prepared query
146
+ on the engine, invalidated by any config change) are bounded only by the
147
+ process. If queries are dynamic or attacker-shaped, cap and canonicalize
148
+ them in the application; treat `query`, `policy source`, and `data` as
149
+ privileged inputs on a multi-tenant host. Rego itself can loop/compute
150
+ without limits, so the caller should apply timeouts/load controls at the
151
+ application layer if policies are untrusted.
152
+ - **Confidentiality:** traces (`trace=True`) capture the plugged values of
153
+ local variables, and Rego `print()` calls see their arguments; both can
154
+ contain secrets from `input` or `data`. Keep them off shared logs, or
155
+ scrub them, when evaluating sensitive inputs. (The default print handler
156
+ writes to stderr.)
@@ -37,6 +37,10 @@ Notes:
37
37
 
38
38
  - `add_data` deep-merges objects; identical values coexist, conflicting values raise
39
39
  `OpaError(code="merge_conflict")` naming the conflicting path.
40
+ - A Go-side panic in any bridge call surfaces as `OpaError(code="panic")`
41
+ instead of killing the process; the engine stays usable afterwards. (The
42
+ bridge also validates `compile_filters(mappings=...)` values are strings
43
+ and rejects malformed shapes with `OpaError(code="invalid_json")`.)
40
44
  - `register_function` infers arity from the callable's signature. A `*args` function is
41
45
  variadic and is called from Rego with a single array argument: `many(["a", "b"])`
42
46
  (OPA does not support variadic builtins with return values).
@@ -105,3 +109,22 @@ Notes:
105
109
  - Rego `print(...)` output is captured per evaluation: set `engine.print_handler`
106
110
  to a `callable(message, location)` to receive it (default: written to stderr);
107
111
  `engine.last_prints` holds the `(message, location)` pairs of the last eval.
112
+ - **Concurrency:** an engine may be shared across threads — evals are
113
+ serialized by an internal re-entrant lock (builtins may evaluate on their
114
+ own engine). Per-eval state (`last_trace`, `last_coverage`, `last_prints`)
115
+ reflects the most recently *completed* eval, so with a shared engine prefer
116
+ return values over `last_*` attributes, or use one engine per thread.
117
+ - **Resource limits / DoS:** there are no built-in caps. Policy/data sizes,
118
+ the number of engines (call `close()`; `__del__` is best-effort under GC),
119
+ and the set of distinct query strings (each is cached as a prepared query
120
+ on the engine, invalidated by any config change) are bounded only by the
121
+ process. If queries are dynamic or attacker-shaped, cap and canonicalize
122
+ them in the application; treat `query`, `policy source`, and `data` as
123
+ privileged inputs on a multi-tenant host. Rego itself can loop/compute
124
+ without limits, so the caller should apply timeouts/load controls at the
125
+ application layer if policies are untrusted.
126
+ - **Confidentiality:** traces (`trace=True`) capture the plugged values of
127
+ local variables, and Rego `print()` calls see their arguments; both can
128
+ contain secrets from `input` or `data`. Keep them off shared logs, or
129
+ scrub them, when evaluating sensitive inputs. (The default print handler
130
+ writes to stderr.)
@@ -5,9 +5,10 @@ package main
5
5
 
6
6
  // Callback provided by the host (Python). It receives the engine handle, the
7
7
  // builtin name and the arguments as a JSON array. It returns a pointer to a
8
- // JSON envelope {"result": ...} or {"error": "..."}. The buffer is owned by
9
- // the host and must stay valid until the callback is invoked again; the Go
10
- // side copies it synchronously.
8
+ // malloc'd JSON envelope {"result": ...} or {"error": "..."}. Ownership of the
9
+ // buffer transfers to the Go side, which copies it synchronously and frees it
10
+ // with the C allocator (see builtins.go). The host must not retain or reuse
11
+ // the pointer after the callback returns.
11
12
  typedef char* (*opa_callback)(unsigned long long h, char* name, char* argsJson);
12
13
  */
13
14
  import "C"
@@ -92,6 +93,18 @@ func errorJSON(code, msg string) *C.char {
92
93
  return C.CString(string(b))
93
94
  }
94
95
 
96
+ // guard converts a panic in fn into a JSON error instead of letting it kill
97
+ // the whole host process. Panics may fire while e.mu is held; deferred
98
+ // unlocks still run during unwinding, so the engine stays usable.
99
+ func guard(fn func() *C.char) (r *C.char) {
100
+ defer func() {
101
+ if rec := recover(); rec != nil {
102
+ r = errorJSON("panic", fmt.Sprint(rec))
103
+ }
104
+ }()
105
+ return fn()
106
+ }
107
+
95
108
  //export OpaNew
96
109
  func OpaNew() C.ulonglong {
97
110
  registryMu.Lock()
@@ -107,6 +120,7 @@ func OpaNew() C.ulonglong {
107
120
 
108
121
  //export OpaDestroy
109
122
  func OpaDestroy(h C.ulonglong) {
123
+ defer func() { recover() }() // keep the host alive on any internal panic
110
124
  registryMu.Lock()
111
125
  defer registryMu.Unlock()
112
126
  delete(registry, uint64(h))
@@ -114,11 +128,16 @@ func OpaDestroy(h C.ulonglong) {
114
128
 
115
129
  //export OpaFreeString
116
130
  func OpaFreeString(s *C.char) {
131
+ defer func() { recover() }()
117
132
  C.free(unsafe.Pointer(s))
118
133
  }
119
134
 
120
135
  //export OpaAddPolicy
121
136
  func OpaAddPolicy(h C.ulonglong, path, src *C.char) *C.char {
137
+ return guard(func() *C.char { return opaAddPolicy(h, path, src) })
138
+ }
139
+
140
+ func opaAddPolicy(h C.ulonglong, path, src *C.char) *C.char {
122
141
  e, err := getEngine(h)
123
142
  if err != nil {
124
143
  return errorJSON("invalid_handle", err.Error())
@@ -136,6 +155,10 @@ func OpaAddPolicy(h C.ulonglong, path, src *C.char) *C.char {
136
155
 
137
156
  //export OpaAddData
138
157
  func OpaAddData(h C.ulonglong, dataPath, jsonValue *C.char) *C.char {
158
+ return guard(func() *C.char { return opaAddData(h, dataPath, jsonValue) })
159
+ }
160
+
161
+ func opaAddData(h C.ulonglong, dataPath, jsonValue *C.char) *C.char {
139
162
  e, err := getEngine(h)
140
163
  if err != nil {
141
164
  return errorJSON("invalid_handle", err.Error())
@@ -170,6 +193,10 @@ func OpaAddData(h C.ulonglong, dataPath, jsonValue *C.char) *C.char {
170
193
 
171
194
  //export OpaRegisterBuiltin
172
195
  func OpaRegisterBuiltin(h C.ulonglong, name *C.char, arity C.int, cb C.opa_callback) *C.char {
196
+ return guard(func() *C.char { return opaRegisterBuiltin(h, name, arity, cb) })
197
+ }
198
+
199
+ func opaRegisterBuiltin(h C.ulonglong, name *C.char, arity C.int, cb C.opa_callback) *C.char {
173
200
  e, err := getEngine(h)
174
201
  if err != nil {
175
202
  return errorJSON("invalid_handle", err.Error())
@@ -189,17 +216,21 @@ func OpaRegisterBuiltin(h C.ulonglong, name *C.char, arity C.int, cb C.opa_callb
189
216
 
190
217
  //export OpaEvalQuery
191
218
  func OpaEvalQuery(h C.ulonglong, query, inputJson *C.char, coverage, trace C.int) *C.char {
192
- return evalCommon(h, C.GoString(query), inputJson, coverage != 0, trace != 0)
219
+ return guard(func() *C.char {
220
+ return evalCommon(h, C.GoString(query), inputJson, coverage != 0, trace != 0)
221
+ })
193
222
  }
194
223
 
195
224
  //export OpaEvalDocument
196
225
  func OpaEvalDocument(h C.ulonglong, docPath, inputJson *C.char, coverage, trace C.int) *C.char {
197
- p := C.GoString(docPath)
198
- q := "data"
199
- if p != "" {
200
- q = "data." + p
201
- }
202
- return evalCommon(h, q, inputJson, coverage != 0, trace != 0)
226
+ return guard(func() *C.char {
227
+ p := C.GoString(docPath)
228
+ q := "data"
229
+ if p != "" {
230
+ q = "data." + p
231
+ }
232
+ return evalCommon(h, q, inputJson, coverage != 0, trace != 0)
233
+ })
203
234
  }
204
235
 
205
236
  func evalCommon(h C.ulonglong, query string, inputJson *C.char, coverage, trace bool) *C.char {
@@ -207,9 +238,12 @@ func evalCommon(h C.ulonglong, query string, inputJson *C.char, coverage, trace
207
238
  if err != nil {
208
239
  return errorJSON("invalid_handle", err.Error())
209
240
  }
210
- e.mu.Lock()
211
- pq, ok := e.prepared[query]
212
- if !ok {
241
+ pq, err := func() (rego.PreparedEvalQuery, error) {
242
+ e.mu.Lock()
243
+ defer e.mu.Unlock()
244
+ if pq, ok := e.prepared[query]; ok {
245
+ return pq, nil
246
+ }
213
247
  opts := []func(*rego.Rego){
214
248
  rego.Query(query),
215
249
  rego.Store(inmem.NewFromObject(e.data)),
@@ -222,14 +256,17 @@ func evalCommon(h C.ulonglong, query string, inputJson *C.char, coverage, trace
222
256
  for _, b := range e.builtins {
223
257
  opts = append(opts, makeBuiltin(uint64(h), b))
224
258
  }
225
- pq, err = rego.New(opts...).PrepareForEval(context.Background())
259
+ pq, err := rego.New(opts...).PrepareForEval(context.Background())
226
260
  if err != nil {
227
- e.mu.Unlock()
228
- return errorJSON("prepare_error", err.Error())
261
+ var zero rego.PreparedEvalQuery
262
+ return zero, err
229
263
  }
230
264
  e.prepared[query] = pq
265
+ return pq, nil
266
+ }()
267
+ if err != nil {
268
+ return errorJSON("prepare_error", err.Error())
231
269
  }
232
- e.mu.Unlock()
233
270
 
234
271
  evalOpts := []rego.EvalOption{}
235
272
  if inputJson != nil {
@@ -62,12 +62,16 @@ func makeBuiltin(handle uint64, spec builtinSpec) func(*rego.Rego) {
62
62
  if ret == nil {
63
63
  return nil, fmt.Errorf("%s: callback returned NULL", spec.name)
64
64
  }
65
- // The host owns ret's buffer; copy it, do not free.
65
+ // The callback mallocs the response buffer and transfers ownership
66
+ // to us: copy the string out, then free with the C allocator via
67
+ // OpaFreeString. Nothing on the host side retains the pointer.
68
+ s := C.GoString(ret)
69
+ C.free(unsafe.Pointer(ret))
66
70
  var envelope struct {
67
71
  Result *json.RawMessage `json:"result"`
68
72
  Error *string `json:"error"`
69
73
  }
70
- if err := json.Unmarshal([]byte(C.GoString(ret)), &envelope); err != nil {
74
+ if err := json.Unmarshal([]byte(s), &envelope); err != nil {
71
75
  return nil, fmt.Errorf("%s: invalid callback response: %w", spec.name, err)
72
76
  }
73
77
  if envelope.Error != nil {
@@ -8,6 +8,7 @@ import "C"
8
8
  import (
9
9
  "context"
10
10
  "encoding/json"
11
+ "fmt"
11
12
 
12
13
  "github.com/open-policy-agent/opa/v1/ast"
13
14
  "github.com/open-policy-agent/opa/v1/rego"
@@ -23,6 +24,12 @@ import (
23
24
  //
24
25
  //export OpaCompileFilters
25
26
  func OpaCompileFilters(h C.ulonglong, query, inputJson, unknownsJson, target, dialect, mappingsJson, maskRule *C.char) *C.char {
27
+ return guard(func() *C.char {
28
+ return opaCompileFilters(h, query, inputJson, unknownsJson, target, dialect, mappingsJson, maskRule)
29
+ })
30
+ }
31
+
32
+ func opaCompileFilters(h C.ulonglong, query, inputJson, unknownsJson, target, dialect, mappingsJson, maskRule *C.char) *C.char {
26
33
  e, err := getEngine(h)
27
34
  if err != nil {
28
35
  return errorJSON("invalid_handle", err.Error())
@@ -56,6 +63,9 @@ func OpaCompileFilters(h C.ulonglong, query, inputJson, unknownsJson, target, di
56
63
  if err := json.Unmarshal([]byte(s), &mappings); err != nil {
57
64
  return errorJSON("invalid_json", err.Error())
58
65
  }
66
+ if err := validateMappings(mappings); err != nil {
67
+ return errorJSON("invalid_json", err.Error())
68
+ }
59
69
  copts = append(copts, regocompile.Mappings(mappings))
60
70
  }
61
71
 
@@ -67,18 +77,21 @@ func OpaCompileFilters(h C.ulonglong, query, inputJson, unknownsJson, target, di
67
77
  copts = append(copts, regocompile.MaskRule(ref))
68
78
  }
69
79
 
70
- e.mu.Lock()
71
- ropts := []func(*rego.Rego){
72
- rego.Store(inmem.NewFromObject(e.data)),
73
- rego.StrictBuiltinErrors(true),
74
- }
75
- for path, src := range e.modules {
76
- ropts = append(ropts, rego.Module(path, src))
77
- }
78
- for _, b := range e.builtins {
79
- ropts = append(ropts, makeBuiltin(uint64(h), b))
80
- }
81
- e.mu.Unlock()
80
+ ropts := func() []func(*rego.Rego) {
81
+ e.mu.Lock()
82
+ defer e.mu.Unlock()
83
+ ropts := []func(*rego.Rego){
84
+ rego.Store(inmem.NewFromObject(e.data)),
85
+ rego.StrictBuiltinErrors(true),
86
+ }
87
+ for path, src := range e.modules {
88
+ ropts = append(ropts, rego.Module(path, src))
89
+ }
90
+ for _, b := range e.builtins {
91
+ ropts = append(ropts, makeBuiltin(uint64(h), b))
92
+ }
93
+ return ropts
94
+ }()
82
95
  copts = append(copts, regocompile.Rego(ropts...))
83
96
 
84
97
  prepared, err := regocompile.New(copts...).Prepare(context.Background())
@@ -104,3 +117,28 @@ func OpaCompileFilters(h C.ulonglong, query, inputJson, unknownsJson, target, di
104
117
  f := filters.For(tgt, dia)
105
118
  return resultJSON(map[string]any{"query": f.Query, "masks": f.Masks})
106
119
  }
120
+
121
+ // validateMappings enforces the shape OPA's translator assumes before the
122
+ // mappings reach unchecked type assertions there: the top level maps table
123
+ // (or unknown-short) names to objects; each entry maps column names to
124
+ // strings, optionally alongside the string-valued "$self"/"$table" keys.
125
+ // This returns an error instead of letting a malformed value panic and take
126
+ // the whole host process down.
127
+ func validateMappings(mappings map[string]any) error {
128
+ for table, tableMapping := range mappings {
129
+ tm, ok := tableMapping.(map[string]any)
130
+ if !ok {
131
+ return fmt.Errorf("mappings[%q]: table entry must be an object, got %T", table, tableMapping)
132
+ }
133
+ for column, columnMapping := range tm {
134
+ if _, ok := columnMapping.(string); ok {
135
+ continue
136
+ }
137
+ if column == "$self" || column == "$table" {
138
+ return fmt.Errorf("mappings[%q].%s: value must be a string, got %T", table, column, columnMapping)
139
+ }
140
+ return fmt.Errorf("mappings[%q].%q: column entry must be a string, got %T", table, column, columnMapping)
141
+ }
142
+ }
143
+ return nil
144
+ }
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "opa-golib-python-bindings"
3
- version = "0.4.0"
3
+ version = "0.4.2"
4
4
  description = "Python bindings for the OPA (Open Policy Agent) Rego engine via a Go c-shared library"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -6,9 +6,10 @@ from pathlib import Path
6
6
  _LIB_PATH = Path(__file__).parent / "libopabridge.so"
7
7
 
8
8
  # char* (*opa_callback)(unsigned long long h, char* name, char* argsJson)
9
- # Return type is c_void_p so ctypes does not copy/free: the Python side keeps
10
- # the returned buffer alive until the callback is invoked again, and Go copies
11
- # it synchronously.
9
+ # Return type is c_void_p so ctypes does not copy/free. The buffer is
10
+ # malloc'd by the host with the C allocator and ownership transfers to the
11
+ # Go side, which copies it and frees it with the same allocator; nothing on
12
+ # the Python side retains the pointer.
12
13
  CALLBACK = ctypes.CFUNCTYPE(
13
14
  ctypes.c_void_p, ctypes.c_uint64, ctypes.c_char_p, ctypes.c_char_p
14
15
  )
@@ -4,6 +4,7 @@ import ctypes
4
4
  import inspect
5
5
  import json
6
6
  import sys
7
+ import threading
7
8
 
8
9
  from . import _native
9
10
 
@@ -21,6 +22,7 @@ class OpaUndefinedError(OpaError):
21
22
 
22
23
 
23
24
  _lib = None
25
+ _libc = None
24
26
 
25
27
 
26
28
  def _get_lib():
@@ -30,20 +32,59 @@ def _get_lib():
30
32
  return _lib
31
33
 
32
34
 
35
+ def _get_libc():
36
+ """Handle for libc's malloc: callback response buffers are owned by the
37
+ Go side, which frees them with the C allocator (see _dispatch)."""
38
+ global _libc
39
+ if _libc is None:
40
+ _libc = ctypes.CDLL(None)
41
+ _libc.malloc.restype = ctypes.c_void_p
42
+ _libc.malloc.argtypes = [ctypes.c_size_t]
43
+ return _libc
44
+
45
+
33
46
  class OpaEngine:
34
47
  """An embedded OPA policy engine instance.
35
48
 
36
49
  Policies are Rego modules added under a path, data is deep-merged JSON,
37
50
  and Python callables can be registered as custom Rego builtins.
51
+
52
+ Concurrency and resource notes:
53
+
54
+ - A single engine may be shared across threads: evaluations are
55
+ serialized by an internal re-entrant lock (which also allows a builtin
56
+ to evaluate on its own engine). Configuration calls (``add_policy``,
57
+ ``add_data``, ``register_function``) may interleave with evals from
58
+ other threads, as in the Go engine itself.
59
+ - Per-eval state (``last_coverage``, ``last_trace``, ``last_prints``)
60
+ reflects the most recently *completed* eval; under concurrent evals on
61
+ a shared engine these may interleave. Use one engine per thread, or
62
+ rely on return values instead of the ``last_*`` attributes, if that
63
+ matters.
64
+ - There are no built-in caps on memory or CPU: policy and data size,
65
+ number of engines (call ``close()`` to release one), and the number of
66
+ distinct queries evaluated are all bounded only by the process. Each
67
+ distinct query string is kept in a prepared-query cache on the engine
68
+ (invalidated by any configuration change), so applications that
69
+ evaluate unbounded attacker-shaped query strings grow memory without
70
+ bound — keep queries a fixed, application-controlled set.
38
71
  """
39
72
 
40
73
  def __init__(self):
41
74
  self._lib = _get_lib()
75
+ self._libc = _get_libc()
42
76
  self._handle = self._lib.OpaNew()
43
77
  self._functions = {}
44
- # Buffers returned to Go must outlive the call; Go copies them
45
- # synchronously, so keeping the latest buffer per builtin suffices.
46
- self._callback_buffers = {}
78
+ # Serializes evals per engine so the per-eval callback buffer list
79
+ # below stays consistent even when multiple threads share an engine
80
+ # or a builtin re-enters eval on the same thread. A plain RLock: Go
81
+ # callbacks for this engine's builtins run on the calling thread while
82
+ # it holds the lock.
83
+ self._eval_lock = threading.RLock()
84
+ # Callback responses are malloc'd with the C allocator; Go frees them
85
+ # with OpaFreeString after copying (ownership transfer), so Python
86
+ # must NOT keep references. Buffers are only alive between _dispatch
87
+ # and the matching OpaFreeString, which the eval lock makes safe.
47
88
  self._trampoline = _native.CALLBACK(self._dispatch)
48
89
  #: Called as ``print_handler(message, location)`` for each Rego
49
90
  #: ``print(...)`` during evaluation; None writes them to stderr.
@@ -133,13 +174,20 @@ class OpaEngine:
133
174
  except Exception as e: # never let an exception cross into Go
134
175
  response = {"error": repr(e)}
135
176
  try:
136
- buf = ctypes.create_string_buffer(json.dumps(response).encode())
177
+ raw = json.dumps(response).encode()
137
178
  except Exception as e:
138
- buf = ctypes.create_string_buffer(
139
- json.dumps({"error": f"unserializable result: {e!r}"}).encode()
140
- )
141
- self._callback_buffers[name] = buf
142
- return ctypes.addressof(buf)
179
+ raw = json.dumps({"error": f"unserializable result: {e!r}"}).encode()
180
+ # malloc with the C allocator: ownership of this buffer transfers to
181
+ # Go, which frees it with OpaFreeString (libc free) after copying.
182
+ # Nothing on the Python side retains the pointer. It must not be
183
+ # created with ctypes buffers — those use Python's allocator, which
184
+ # is not compatible with libc free.
185
+ buf = self._libc.malloc(len(raw) + 1)
186
+ if not buf:
187
+ return None # Go reports "callback returned NULL"
188
+ ctypes.memmove(buf, raw, len(raw))
189
+ ctypes.memset(buf + len(raw), 0, 1)
190
+ return buf
143
191
 
144
192
  # -- public API ----------------------------------------------------
145
193
 
@@ -197,16 +245,17 @@ class OpaEngine:
197
245
  at each step, is stored in ``self.last_trace``.
198
246
  """
199
247
  self._check_open()
200
- self._eval_reset()
201
- rs = self._eval_result(
202
- self._call(
203
- self._lib.OpaEvalQuery,
204
- query.encode(),
205
- _encode_input(input),
206
- int(coverage),
207
- int(trace),
248
+ with self._eval_lock:
249
+ self._eval_reset()
250
+ rs = self._eval_result(
251
+ self._call(
252
+ self._lib.OpaEvalQuery,
253
+ query.encode(),
254
+ _encode_input(input),
255
+ int(coverage),
256
+ int(trace),
257
+ )
208
258
  )
209
- )
210
259
  if not rs:
211
260
  return []
212
261
  return [r.get("bindings", {}) for r in rs]
@@ -221,16 +270,17 @@ class OpaEngine:
221
270
  with ``trace=True`` the event trace is stored in ``self.last_trace``.
222
271
  """
223
272
  self._check_open()
224
- self._eval_reset()
225
- rs = self._eval_result(
226
- self._call(
227
- self._lib.OpaEvalDocument,
228
- path.encode(),
229
- _encode_input(input),
230
- int(coverage),
231
- int(trace),
273
+ with self._eval_lock:
274
+ self._eval_reset()
275
+ rs = self._eval_result(
276
+ self._call(
277
+ self._lib.OpaEvalDocument,
278
+ path.encode(),
279
+ _encode_input(input),
280
+ int(coverage),
281
+ int(trace),
282
+ )
232
283
  )
233
- )
234
284
  if not rs or not rs[0].get("expressions"):
235
285
  raise OpaUndefinedError(f"data.{path}" if path else "data")
236
286
  return rs[0]["expressions"][0]["value"]
@@ -279,16 +329,17 @@ class OpaEngine:
279
329
  is fine, as it resolves during partial evaluation.
280
330
  """
281
331
  self._check_open()
282
- envelope = self._call(
283
- self._lib.OpaCompileFilters,
284
- query.encode(),
285
- _encode_input(input),
286
- json.dumps(list(unknowns)).encode(),
287
- target.encode(),
288
- dialect.encode(),
289
- b"" if mappings is None else json.dumps(mappings).encode(),
290
- (mask_rule or "").encode(),
291
- )
332
+ with self._eval_lock:
333
+ envelope = self._call(
334
+ self._lib.OpaCompileFilters,
335
+ query.encode(),
336
+ _encode_input(input),
337
+ json.dumps(list(unknowns)).encode(),
338
+ target.encode(),
339
+ dialect.encode(),
340
+ b"" if mappings is None else json.dumps(mappings).encode(),
341
+ (mask_rule or "").encode(),
342
+ )
292
343
  return envelope.get("result")
293
344
 
294
345