Answer AST name queries for the source fact on /facts
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful

This commit is contained in:
2026-10-09 23:41:57 +11:00
parent 7e2d5f7115
commit 0aff1746e2
5 changed files with 331 additions and 11 deletions
+142
View File
@@ -3,6 +3,8 @@ package main
import (
"encoding/json"
"log"
"regexp"
"slices"
"strings"
)
@@ -204,3 +206,143 @@ func constrainsField(parts []json.RawMessage, field string) bool {
var name string
return json.Unmarshal(parts[1], &name) == nil && name == field
}
// sourceQuery returns the parsed /facts query when one of its name comparisons
// selects the owned fact and the whole query can be evaluated locally against a
// fact record; nil otherwise, which leaves the query on the gated path.
// ponytail: a negated name match (["not",["=","name","x"]]) does not select the
// owned fact; add three-valued evaluation if a client needs it.
func (si *sourceInjector) sourceQuery(query string) []json.RawMessage {
if si == nil {
return nil
}
var ast []json.RawMessage
if json.Unmarshal([]byte(query), &ast) != nil {
return nil
}
probe := factFields{Name: si.name}
if _, ok := matchFact(ast, probe); !ok || !namesFact(ast, probe) {
return nil
}
return ast
}
// factFields are the queryable columns of the facts entity (openvoxdb
// engine.clj facts-query). value is a string here: the synthetic value always is.
type factFields struct {
Certname string `json:"certname"`
Environment string `json:"environment"`
Name string `json:"name"`
Value string `json:"value"`
}
func (f factFields) column(name string) (string, bool) {
switch name {
case "certname":
return f.Certname, true
case "environment":
return f.Environment, true
case "name":
return f.Name, true
case "value":
return f.Value, true
}
return "", false
}
// namesFact reports whether a name comparison in the boolean skeleton matches f.
func namesFact(parts []json.RawMessage, f factFields) bool {
var op, field string
if len(parts) < 2 || json.Unmarshal(parts[0], &op) != nil {
return false
}
switch op {
case "and", "or", "not":
for _, p := range parts[1:] {
var sub []json.RawMessage
if json.Unmarshal(p, &sub) == nil && namesFact(sub, f) {
return true
}
}
return false
}
if json.Unmarshal(parts[1], &field) != nil || field != "name" {
return false
}
match, _ := matchFact(parts, f)
return match
}
// matchFact evaluates a facts query against one record with openvoxdb's
// semantics for and/or/not, =, ~ and in-array. ok is false for anything else,
// including subqueries, so a caller can refuse rather than guess.
func matchFact(parts []json.RawMessage, f factFields) (match, ok bool) {
var op string
if len(parts) == 0 || json.Unmarshal(parts[0], &op) != nil {
return false, false
}
switch op {
case "and", "or":
match = op == "and"
for _, p := range parts[1:] {
var sub []json.RawMessage
if json.Unmarshal(p, &sub) != nil {
return false, false
}
m, ok := matchFact(sub, f)
if !ok {
return false, false
}
if op == "and" {
match = match && m
} else {
match = match || m
}
}
return match, true
case "not":
var sub []json.RawMessage
if len(parts) != 2 || json.Unmarshal(parts[1], &sub) != nil {
return false, false
}
m, ok := matchFact(sub, f)
return !m, ok
}
var field string
if len(parts) != 3 || json.Unmarshal(parts[1], &field) != nil {
return false, false
}
got, known := f.column(field)
if !known {
return false, false
}
switch op {
case "=":
var want any
if json.Unmarshal(parts[2], &want) != nil {
return false, false
}
return want == any(got), true
case "~":
var pattern string
if json.Unmarshal(parts[2], &pattern) != nil {
return false, false
}
re, err := regexp.Compile(pattern)
if err != nil {
return false, false
}
return re.MatchString(got), true
case "in":
var arr []json.RawMessage
var tag string
var values []any
if json.Unmarshal(parts[2], &arr) != nil || len(arr) != 2 ||
json.Unmarshal(arr[0], &tag) != nil || tag != "array" ||
json.Unmarshal(arr[1], &values) != nil {
return false, false
}
return slices.Contains(values, any(got)), true
}
return false, false
}