Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions libs/cmdio/filter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package cmdio

import (
"encoding/json"
"fmt"
"strings"
)

// applyInputOnlyMask returns v with the listed dotted paths removed. If
// paths is empty, v is returned unchanged. Otherwise v is round-tripped
// through JSON into a generic representation, the paths are deleted, and
// the masked value is returned for the caller to marshal in its preferred
// format.
//
// Paths use dotted notation (e.g. "stable_url.initial_workspace_id").
// Arrays and dynamically-keyed maps (e.g. proto map<string, V>) are
// traversed transparently: a single path applies to every element of an
// array, and to every value of a map when no literal key matches the
// next path component. List responses and map-valued fields therefore
// share the same path expression as singletons.
func applyInputOnlyMask(v any, paths []string) (any, error) {
if len(paths) == 0 {
return v, nil
}
b, err := json.Marshal(v)
if err != nil {
return nil, fmt.Errorf("input-only mask: marshal: %w", err)
}
var out any
if err := json.Unmarshal(b, &out); err != nil {
return nil, fmt.Errorf("input-only mask: unmarshal: %w", err)
}
for _, p := range paths {
deletePath(out, strings.Split(p, "."))
}
return out, nil
}

// deletePath walks v according to keys and removes the leaf key from any
// object it lands on.
//
// Both arrays and dynamically-keyed maps are traversed transparently:
//
// - When v is a []any, every element is visited with the same key list.
// - When v is a map[string]any but the next key is not a literal match,
// every value is visited with the same key list — this handles proto
// map<string, V> fields, whose JSON keys are user-provided strings and
// whose values carry the field name from the path.
//
// Both struct fields and proto map<string, V> surface as map[string]any
// after json.Unmarshal, so a single corner case remains: if a map's
// user-provided key happens to equal an inner field name, the literal
// match wins and that entry is removed instead of the field inside each
// value. Genkit emits paths from the schema, and this matches the
// expected behavior for any path the schema actually targets.
func deletePath(v any, keys []string) {
if len(keys) == 0 {
return
}
switch t := v.(type) {
case map[string]any:
if child, ok := t[keys[0]]; ok {
if len(keys) == 1 {
delete(t, keys[0])
} else {
deletePath(child, keys[1:])
}
return
}
for _, child := range t {
deletePath(child, keys)
}
case []any:
for _, el := range t {
deletePath(el, keys)
}
}
}
172 changes: 172 additions & 0 deletions libs/cmdio/filter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package cmdio

import (
"bytes"
"encoding/json"
"testing"

"github.com/databricks/cli/libs/flags"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

type stableURL struct {
Name string `json:"name"`
InitialWorkspaceID string `json:"initial_workspace_id"`
URL string `json:"url,omitempty"`
}

type stableURLList struct {
StableURLs []stableURL `json:"stable_urls"`
}

type wrapper struct {
StableURL stableURL `json:"stable_url"`
}

type taggedStableURLs struct {
Tags map[string]stableURL `json:"tags"`
}

type nestedMapWrapper struct {
Spec struct {
Tags map[string]stableURL `json:"tags"`
} `json:"spec"`
}

func TestApplyInputOnlyMaskEmptyPathsReturnsValueUnchanged(t *testing.T) {
in := stableURL{Name: "n", InitialWorkspaceID: "w"}
out, err := applyInputOnlyMask(in, nil)
require.NoError(t, err)
assert.Equal(t, in, out)
}

func TestApplyInputOnlyMaskFlatField(t *testing.T) {
in := stableURL{Name: "n", InitialWorkspaceID: "w", URL: "u"}
out, err := applyInputOnlyMask(in, []string{"initial_workspace_id"})
require.NoError(t, err)
b, err := json.Marshal(out)
require.NoError(t, err)
assert.JSONEq(t, `{"name":"n","url":"u"}`, string(b))
}

func TestApplyInputOnlyMaskFieldAbsentIsNoop(t *testing.T) {
in := stableURL{Name: "n"}
out, err := applyInputOnlyMask(in, []string{"missing_field"})
require.NoError(t, err)
b, err := json.Marshal(out)
require.NoError(t, err)
// Name retained; missing path silently ignored. InitialWorkspaceID
// stays present at "" because the struct tag has no omitempty.
assert.JSONEq(t, `{"name":"n","initial_workspace_id":""}`, string(b))
}

func TestApplyInputOnlyMaskNested(t *testing.T) {
in := wrapper{StableURL: stableURL{Name: "n", InitialWorkspaceID: "w"}}
out, err := applyInputOnlyMask(in, []string{"stable_url.initial_workspace_id"})
require.NoError(t, err)
b, err := json.Marshal(out)
require.NoError(t, err)
assert.JSONEq(t, `{"stable_url":{"name":"n"}}`, string(b))
}

func TestApplyInputOnlyMaskSliceElements(t *testing.T) {
in := stableURLList{StableURLs: []stableURL{
{Name: "a", InitialWorkspaceID: "1"},
{Name: "b", InitialWorkspaceID: "2"},
}}
out, err := applyInputOnlyMask(in, []string{"stable_urls.initial_workspace_id"})
require.NoError(t, err)
b, err := json.Marshal(out)
require.NoError(t, err)
assert.JSONEq(t, `{"stable_urls":[{"name":"a"},{"name":"b"}]}`, string(b))
}

func TestApplyInputOnlyMaskMapValues(t *testing.T) {
in := taggedStableURLs{Tags: map[string]stableURL{
"env": {Name: "a", InitialWorkspaceID: "1"},
"prod": {Name: "b", InitialWorkspaceID: "2"},
}}
out, err := applyInputOnlyMask(in, []string{"tags.initial_workspace_id"})
require.NoError(t, err)
b, err := json.Marshal(out)
require.NoError(t, err)

var got struct {
Tags map[string]map[string]any `json:"tags"`
}
require.NoError(t, json.Unmarshal(b, &got))
require.Len(t, got.Tags, 2)
for k, v := range got.Tags {
assert.NotContains(t, v, "initial_workspace_id", "tag %q should have initial_workspace_id stripped", k)
assert.Contains(t, v, "name", "tag %q should retain name", k)
}
}

func TestApplyInputOnlyMaskNestedInMapValue(t *testing.T) {
// Path lands inside a map at the second segment, then descends two
// more levels into the map's value. Confirms map transparency
// composes with regular literal-key descent.
var in nestedMapWrapper
in.Spec.Tags = map[string]stableURL{
"a": {Name: "x", InitialWorkspaceID: "1"},
"b": {Name: "y", InitialWorkspaceID: "2"},
}
out, err := applyInputOnlyMask(in, []string{"spec.tags.initial_workspace_id"})
require.NoError(t, err)
b, err := json.Marshal(out)
require.NoError(t, err)

var got struct {
Spec struct {
Tags map[string]map[string]any `json:"tags"`
} `json:"spec"`
}
require.NoError(t, json.Unmarshal(b, &got))
require.Len(t, got.Spec.Tags, 2)
for k, v := range got.Spec.Tags {
assert.NotContains(t, v, "initial_workspace_id", "spec.tags[%q] should have initial_workspace_id stripped", k)
}
}

func TestApplyInputOnlyMaskMultiplePaths(t *testing.T) {
in := stableURL{Name: "n", InitialWorkspaceID: "w", URL: "u"}
out, err := applyInputOnlyMask(in, []string{"initial_workspace_id", "url"})
require.NoError(t, err)
b, err := json.Marshal(out)
require.NoError(t, err)
assert.JSONEq(t, `{"name":"n"}`, string(b))
}

// TestRenderFilteredStripsInputOnlyField is the integration check: pass
// the same StableUrl-like value the CLI would receive from the SDK, and
// confirm the rendered JSON does not contain initial_workspace_id.
func TestRenderFilteredStripsInputOnlyField(t *testing.T) {
v := stableURL{Name: "accounts/x/stable-urls/y", InitialWorkspaceID: "ws-1", URL: "https://example.test"}

out := &bytes.Buffer{}
c := &cmdIO{
capabilities: Capabilities{},
outputFormat: flags.OutputJSON,
out: out,
err: out,
}
ctx := InContext(t.Context(), c)
require.NoError(t, RenderFiltered(ctx, v, []string{"initial_workspace_id"}))

assert.JSONEq(t, `{"name":"accounts/x/stable-urls/y","url":"https://example.test"}`, out.String())
assert.NotContains(t, out.String(), "initial_workspace_id")
}

func TestRenderFilteredNoPathsMatchesRender(t *testing.T) {
v := stableURL{Name: "n", InitialWorkspaceID: "w"}

want := &bytes.Buffer{}
got := &bytes.Buffer{}
mk := func(buf *bytes.Buffer) *cmdIO {
return &cmdIO{capabilities: Capabilities{}, outputFormat: flags.OutputJSON, out: buf, err: buf}
}
require.NoError(t, Render(InContext(t.Context(), mk(want)), v))
require.NoError(t, RenderFiltered(InContext(t.Context(), mk(got)), v, nil))
assert.Equal(t, want.String(), got.String())
}
2 changes: 1 addition & 1 deletion libs/cmdio/paged_template_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ func TestPagedTemplateMatchesNonPagedForSmallList(t *testing.T) {

var expected bytes.Buffer
refIter := listing.Iterator[int](&numberIterator{n: rows})
require.NoError(t, renderWithTemplate(MockDiscard(t.Context()), newIteratorRenderer(refIter), flags.OutputText, &expected, "", tmpl))
require.NoError(t, renderWithTemplate(MockDiscard(t.Context()), newIteratorRenderer(refIter, nil), flags.OutputText, &expected, "", tmpl))

pagedIter := listing.Iterator[int](&numberIterator{n: rows})
var actual bytes.Buffer
Expand Down
55 changes: 43 additions & 12 deletions libs/cmdio/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,9 @@ func (r readerRenderer) renderText(_ context.Context, w io.Writer) error {
}

type iteratorRenderer[T any] struct {
t listing.Iterator[T]
bufferSize int
t listing.Iterator[T]
bufferSize int
inputOnlyPaths []string
}

func (ir iteratorRenderer[T]) getBufferSize() int {
Expand Down Expand Up @@ -117,7 +118,11 @@ func (ir iteratorRenderer[T]) renderJson(ctx context.Context, w writeFlusher) er
if err != nil {
return err
}
res, err := json.MarshalIndent(n, " ", " ")
masked, err := applyInputOnlyMask(n, ir.inputOnlyPaths)
if err != nil {
return err
}
res, err := json.MarshalIndent(masked, " ", " ")
if err != nil {
return err
}
Expand Down Expand Up @@ -173,12 +178,17 @@ func (ir iteratorRenderer[T]) renderTemplate(ctx context.Context, t *template.Te
}

type defaultRenderer struct {
t any
t any
inputOnlyPaths []string
}

func (d defaultRenderer) renderJson(ctx context.Context, w writeFlusher) error {
c := fromContext(ctx)
pretty, err := marshalJSON(d.t, c.capabilities.SupportsStdoutColor())
v, err := applyInputOnlyMask(d.t, d.inputOnlyPaths)
if err != nil {
return err
}
pretty, err := marshalJSON(v, c.capabilities.SupportsStdoutColor())
if err != nil {
return err
}
Expand All @@ -201,15 +211,20 @@ func (d defaultRenderer) renderTemplate(_ context.Context, t *template.Template,
// - jsonRenderer
// - textRenderer
// - templateRenderer
func newRenderer(t any) any {
//
// inputOnlyPaths, when non-empty, lists dotted JSON paths that should be
// stripped from the rendered value before it is written to stdout. The
// paths are consulted only by the JSON render path; text/template
// rendering operates on the raw value.
func newRenderer(t any, inputOnlyPaths []string) any {
if r, ok := t.(io.Reader); ok {
return readerRenderer{reader: r}
}
return defaultRenderer{t: t}
return defaultRenderer{t: t, inputOnlyPaths: inputOnlyPaths}
}

func newIteratorRenderer[T any](i listing.Iterator[T]) iteratorRenderer[T] {
return iteratorRenderer[T]{t: i}
func newIteratorRenderer[T any](i listing.Iterator[T], inputOnlyPaths []string) iteratorRenderer[T] {
return iteratorRenderer[T]{t: i, inputOnlyPaths: inputOnlyPaths}
}

type bufferedFlusher struct {
Expand Down Expand Up @@ -266,11 +281,20 @@ type listingInterface interface {
}

func Render(ctx context.Context, v any) error {
return RenderFiltered(ctx, v, nil)
}

// RenderFiltered behaves like Render but strips the given dotted JSON
// paths from the value before it is marshaled. Used by generated CLI
// commands for response types containing INPUT_ONLY fields (which the
// SDK transport struct carries unconditionally) so those fields don't
// leak into user-facing JSON output.
func RenderFiltered(ctx context.Context, v any, inputOnlyPaths []string) error {
c := fromContext(ctx)
if _, ok := v.(listingInterface); ok {
panic("use RenderIterator instead")
}
return renderWithTemplate(ctx, newRenderer(v), c.outputFormat, c.out, c.headerTemplate, c.template)
return renderWithTemplate(ctx, newRenderer(v, inputOnlyPaths), c.outputFormat, c.out, c.headerTemplate, c.template)
}

// RenderIterator renders the items produced by i. When the terminal is
Expand All @@ -280,19 +304,26 @@ func Render(ctx context.Context, v any) error {
// locked from the first batch so columns stay aligned across pages).
// Piped output and JSON output keep the existing non-paged behavior.
func RenderIterator[T any](ctx context.Context, i listing.Iterator[T]) error {
return RenderIteratorFiltered(ctx, i, nil)
}

// RenderIteratorFiltered behaves like RenderIterator but strips the given
// dotted JSON paths from each element before it is marshaled. See
// RenderFiltered for the motivation.
func RenderIteratorFiltered[T any](ctx context.Context, i listing.Iterator[T], inputOnlyPaths []string) error {
c := fromContext(ctx)
if c.capabilities.SupportsPager() && c.outputFormat == flags.OutputText && c.template != "" {
return renderIteratorPagedTemplate(ctx, i, c.in, c.out, c.headerTemplate, c.template)
}
return renderWithTemplate(ctx, newIteratorRenderer(i), c.outputFormat, c.out, c.headerTemplate, c.template)
return renderWithTemplate(ctx, newIteratorRenderer(i, inputOnlyPaths), c.outputFormat, c.out, c.headerTemplate, c.template)
}

func RenderWithTemplate(ctx context.Context, v any, headerTemplate, template string) error {
c := fromContext(ctx)
if _, ok := v.(listingInterface); ok {
panic("listings must use RenderIterator, not RenderWithTemplate")
}
return renderWithTemplate(ctx, newRenderer(v), c.outputFormat, c.out, headerTemplate, template)
return renderWithTemplate(ctx, newRenderer(v, nil), c.outputFormat, c.out, headerTemplate, template)
}

// staticTemplateFuncs are the ctx-independent helpers shared across every
Expand Down
Loading