diff --git a/LICENSE-3rdparty.csv b/LICENSE-3rdparty.csv index 36d206a7..2bc88a8a 100644 --- a/LICENSE-3rdparty.csv +++ b/LICENSE-3rdparty.csv @@ -1,16 +1,32 @@ Component,Origin,License,Copyright -github.com/stretchr/testify,https://github.com/stretchr/testify,MIT,"Copyright (c) 2012-2020 Mat Ryer, Tyler Bunnell and contributors" -gopkg.in/yaml.v3,https://github.com/go-yaml/yaml,MIT AND Apache-2.0,"Copyright (c) 2006-2011 Kirill Simonov, 2011-2019 Canonical Ltd" -mvdan.cc/sh/v3,https://github.com/mvdan/sh,BSD-3-Clause,"Copyright (c) 2016, Daniel Marti" +github.com/DataDog/datadog-agent/pkg/fleet/installer,https://github.com/DataDog/datadog-agent,Apache-2.0,"Copyright 2016-present Datadog, Inc." +github.com/DataDog/datadog-agent/pkg/template,https://github.com/DataDog/datadog-agent,Apache-2.0,"Copyright 2016-present Datadog, Inc." +github.com/DataDog/datadog-agent/pkg/util/log,https://github.com/DataDog/datadog-agent,Apache-2.0,"Copyright 2016-present Datadog, Inc." +github.com/DataDog/datadog-agent/pkg/util/scrubber,https://github.com/DataDog/datadog-agent,Apache-2.0,"Copyright 2016-present Datadog, Inc." +github.com/DataDog/datadog-agent/pkg/version,https://github.com/DataDog/datadog-agent,Apache-2.0,"Copyright 2016-present Datadog, Inc." github.com/davecgh/go-spew,https://github.com/davecgh/go-spew,ISC,Copyright (c) 2012-2016 Dave Collins -github.com/inconshreveable/mousetrap,https://github.com/inconshreveable/mousetrap,Apache-2.0,Copyright 2014 Alan Shreve +github.com/ebitengine/purego,https://github.com/ebitengine/purego,Apache-2.0,Copyright 2022 The Ebitengine Authors +github.com/go-ole/go-ole,https://github.com/go-ole/go-ole,MIT,"Copyright (c) 2013-2017 Yasuhiro Matsumoto" github.com/google/uuid,https://github.com/google/uuid,BSD-3-Clause,Copyright (c) 2018 Google Inc. +github.com/inconshreveable/mousetrap,https://github.com/inconshreveable/mousetrap,Apache-2.0,Copyright 2014 Alan Shreve +github.com/lufia/plan9stats,https://github.com/lufia/plan9stats,BSD-3-Clause,"Copyright (c) 2019, KADOTA, Kyohei" github.com/pmezard/go-difflib,https://github.com/pmezard/go-difflib,BSD-3-Clause,"Copyright (c) 2013, Patrick Mezard" +github.com/power-devops/perfstat,https://github.com/power-devops/perfstat,MIT,Copyright (c) 2020 Power DevOps github.com/prometheus-community/pro-bing,https://github.com/prometheus-community/pro-bing,MIT,"Copyright 2022 The Prometheus Authors; Copyright 2016 Cameron Sparr and contributors" +github.com/shirou/gopsutil/v4,https://github.com/shirou/gopsutil,BSD-3-Clause,"Copyright (c) 2014, WAKAYAMA Shirou" github.com/spf13/cobra,https://github.com/spf13/cobra,Apache-2.0,Copyright 2013-2023 The Cobra Authors github.com/spf13/pflag,https://github.com/spf13/pflag,BSD-3-Clause,"Copyright (c) 2012 Alex Ogier, The Go Authors" +github.com/stretchr/testify,https://github.com/stretchr/testify,MIT,"Copyright (c) 2012-2020 Mat Ryer, Tyler Bunnell and contributors" +github.com/tklauser/go-sysconf,https://github.com/tklauser/go-sysconf,BSD-3-Clause,"Copyright (c) 2018-2022, Tobias Klauser" +github.com/tklauser/numcpus,https://github.com/tklauser/numcpus,Apache-2.0,"Copyright (c) 2018-2022, Tobias Klauser" +github.com/yusufpapurcu/wmi,https://github.com/yusufpapurcu/wmi,MIT,Copyright (c) 2013 Stack Exchange +go.uber.org/atomic,https://github.com/uber-go/atomic,MIT,"Copyright (c) 2016 Uber Technologies, Inc." +go.yaml.in/yaml/v3,https://github.com/yaml/go-yaml,MIT AND Apache-2.0,"Copyright (c) 2006-2011 Kirill Simonov, 2011-2019 Canonical Ltd" +golang.org/x/mod,https://github.com/golang/mod,BSD-3-Clause,Copyright (c) 2009 The Go Authors. All rights reserved. golang.org/x/net,https://github.com/golang/net,BSD-3-Clause,Copyright (c) 2009 The Go Authors. All rights reserved. golang.org/x/sync,https://github.com/golang/sync,BSD-3-Clause,Copyright (c) 2009 The Go Authors. All rights reserved. golang.org/x/sys,https://github.com/golang/sys,BSD-3-Clause,Copyright (c) 2009 The Go Authors. All rights reserved. -golang.org/x/mod,https://github.com/golang/mod,BSD-3-Clause,Copyright (c) 2009 The Go Authors. All rights reserved. +golang.org/x/time,https://github.com/golang/time,BSD-3-Clause,Copyright (c) 2009 The Go Authors. All rights reserved. golang.org/x/tools,https://github.com/golang/tools,BSD-3-Clause,Copyright (c) 2009 The Go Authors. All rights reserved. +gopkg.in/yaml.v3,https://github.com/go-yaml/yaml,MIT AND Apache-2.0,"Copyright (c) 2006-2011 Kirill Simonov, 2011-2019 Canonical Ltd" +mvdan.cc/sh/v3,https://github.com/mvdan/sh,BSD-3-Clause,"Copyright (c) 2016, Daniel Marti" diff --git a/analysis/symbols_interp.go b/analysis/symbols_interp.go index 3c52e7d5..3da6c1a2 100644 --- a/analysis/symbols_interp.go +++ b/analysis/symbols_interp.go @@ -20,10 +20,13 @@ var interpAllowedSymbols = []string{ "bytes.Buffer", // 🟢 in-memory byte buffer; pure data structure, no I/O. "context.Background", // 🟢 returns the empty background context; used in StdIO option where no run-scoped context is available. "context.CancelFunc", // 🟢 function type returned by WithTimeout/WithCancel; pure function type, no side effects. + "context.Canceled", // 🟢 sentinel error returned by ctx.Err() when Done was closed by CancelFunc; used to classify the run-span outcome. "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. + "context.DeadlineExceeded", // 🟢 sentinel error returned by ctx.Err() on deadline; used to classify the run-span outcome. "context.WithTimeout", // 🟢 derives a context with a deadline; needed for execution timeout support. "context.WithValue", // 🟢 derives a context carrying a key-value pair; pure function. "errors.As", // 🟢 error type assertion; pure function, no I/O. + "errors.Is", // 🟢 sentinel-error equivalence check; pure function, no I/O. "errors.New", // 🟢 creates a sentinel error value; pure function, no I/O. "fmt.Errorf", // 🟢 formatted error creation; pure function, no I/O. "fmt.Fprintf", // 🟠 formatted write to an io.Writer; delegates to Write, no filesystem access. @@ -70,6 +73,11 @@ var interpAllowedSymbols = []string{ "time.Now", // 🟠 returns current time; read-only, no mutation. "time.Time", // 🟢 time value type; pure data, no side effects. + // --- github.com/DataDog/datadog-agent/pkg/fleet/installer/telemetry --- (lightweight span tracer used by the Agent Installer) + + "github.com/DataDog/datadog-agent/pkg/fleet/installer/telemetry.Span", // 🟢 pointer type used to hold a span across the if-block that starts it; no side effects from the type reference itself. + "github.com/DataDog/datadog-agent/pkg/fleet/installer/telemetry.StartSpanFromContext", // 🟠 starts a span from a parent carried on ctx; registers with the package-global tracer. No I/O here — flushing happens only if the embedding process has also called NewTelemetry. + // --- mvdan.cc/sh/v3/expand --- (shell word expansion library) "mvdan.cc/sh/v3/expand.Config", // 🟢 configuration for word expansion; pure type. diff --git a/cmd/rshell/main.go b/cmd/rshell/main.go index 91032d03..beeacee4 100644 --- a/cmd/rshell/main.go +++ b/cmd/rshell/main.go @@ -25,7 +25,11 @@ import ( const exitCodeTimeout = 124 func main() { - os.Exit(run(context.Background(), os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) + stopTelemetry := startTelemetry() + code := run(context.Background(), os.Args[1:], os.Stdin, os.Stdout, os.Stderr) + // Flush before os.Exit — os.Exit does not run deferred calls. + stopTelemetry() + os.Exit(code) } func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) int { diff --git a/cmd/rshell/telemetry_off.go b/cmd/rshell/telemetry_off.go new file mode 100644 index 00000000..dfef85ab --- /dev/null +++ b/cmd/rshell/telemetry_off.go @@ -0,0 +1,14 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//go:build !with_telemetry + +package main + +// startTelemetry is a no-op in the default build: spans created inside interp +// register on the global tracer but are never flushed, which is a bounded +// leak acceptable for the lifetime of a short-lived rshell invocation. Build +// with `-tags with_telemetry` to include the real sender (telemetry_on.go). +func startTelemetry() func() { return func() {} } diff --git a/cmd/rshell/telemetry_on.go b/cmd/rshell/telemetry_on.go new file mode 100644 index 00000000..40840cf9 --- /dev/null +++ b/cmd/rshell/telemetry_on.go @@ -0,0 +1,38 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//go:build with_telemetry + +package main + +import ( + "net/http" + "os" + + "github.com/DataDog/datadog-agent/pkg/fleet/installer/telemetry" +) + +// startTelemetry constructs a Telemetry sender using env-var configuration, +// so the spans the interp package registers on the global tracer actually +// get flushed to Datadog intake. Returns a stop function the caller must +// invoke before process exit to synchronously flush completed spans. +// +// Built only when the with_telemetry tag is set; the stock release binary +// uses the no-op variant in telemetry_off.go. +// +// Env vars: +// - DD_API_KEY (required; if unset, returns a no-op — no telemetry is sent) +// - DD_SITE (defaults to datadoghq.com; set to datad0g.com for staging) +// +// The HTTP client is http.DefaultClient, which respects HTTP_PROXY / +// HTTPS_PROXY / NO_PROXY env vars via Go's default transport. +func startTelemetry() func() { + apiKey := os.Getenv("DD_API_KEY") + if apiKey == "" { + return func() {} + } + tel := telemetry.NewTelemetry(http.DefaultClient, apiKey, os.Getenv("DD_SITE"), "rshell") + return tel.Stop +} diff --git a/go.mod b/go.mod index 7be42efe..fcbb372e 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/DataDog/rshell go 1.25.6 require ( + github.com/DataDog/datadog-agent/pkg/fleet/installer v0.78.0 github.com/prometheus-community/pro-bing v0.8.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 @@ -14,11 +15,26 @@ require ( ) require ( - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/DataDog/datadog-agent/pkg/template v0.78.0 // indirect + github.com/DataDog/datadog-agent/pkg/util/log v0.78.0 // indirect + github.com/DataDog/datadog-agent/pkg/util/scrubber v0.78.0 // indirect + github.com/DataDog/datadog-agent/pkg/version v0.78.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/ebitengine/purego v0.10.0 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/shirou/gopsutil/v4 v4.26.2 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/mod v0.34.0 // indirect golang.org/x/net v0.52.0 // indirect golang.org/x/sync v0.20.0 // indirect + golang.org/x/time v0.14.0 // indirect ) diff --git a/go.sum b/go.sum index a8069899..d5ee63a3 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,21 @@ +github.com/DataDog/datadog-agent/pkg/fleet/installer v0.78.0 h1:RAKiDOYMEIzwHXl7BFKWl/u6m81gKQiphjCAkgqEMs0= +github.com/DataDog/datadog-agent/pkg/fleet/installer v0.78.0/go.mod h1:iInwmRMh03UZF39vYp3BMAzfu2xEHgBQKythT/oylg0= +github.com/DataDog/datadog-agent/pkg/template v0.78.0 h1:mJ0Yy9xWhIwOBIpVMaPWNcxSbf9ajn18P4YbL3vyNJA= +github.com/DataDog/datadog-agent/pkg/template v0.78.0/go.mod h1:ZUjICHSlN0of0cmWrYk9Pof0DV0eqHSpTUK1NTnN26Y= +github.com/DataDog/datadog-agent/pkg/util/log v0.78.0 h1:dmFxkpMVX6YXvU1jV7R5eY4FOZ2nHGKWHKx+SAjAdfw= +github.com/DataDog/datadog-agent/pkg/util/log v0.78.0/go.mod h1:QZU/9s1yvOrIExqDwIhLE4cit/3LLE9iJ86FwuvY5ic= +github.com/DataDog/datadog-agent/pkg/util/scrubber v0.78.0 h1:MydZgoGPYjGt8jCwo2wOM9VvpIE7OLi8pXy8wWRL7Oo= +github.com/DataDog/datadog-agent/pkg/util/scrubber v0.78.0/go.mod h1:zM5uAINxu8o9OZGCCvqeH1E/vIsX6baiO2iPf6bs1xs= +github.com/DataDog/datadog-agent/pkg/version v0.78.0 h1:piLsyG7dP/ie6d4MQ17pTh5XPt/BV55dy8910eXiNj0= +github.com/DataDog/datadog-agent/pkg/version v0.78.0/go.mod h1:cOWF+29ZahL6qcC3KAntJEupdMdteiOGaLmIMz8zYBg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -13,13 +28,19 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k= +github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/prometheus-community/pro-bing v0.8.0 h1:CEY/g1/AgERRDjxw5P32ikcOgmrSuXs7xon7ovx6mNc= github.com/prometheus-community/pro-bing v0.8.0/go.mod h1:Idyxz8raDO6TgkUN6ByiEGvWJNyQd40kN9ZUeho3lN0= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI= +github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -27,6 +48,15 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= @@ -34,12 +64,18 @@ golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= mvdan.cc/sh/v3 v3.13.0 h1:dSfq/MVsY4w0Vsi6Lbs0IcQquMVqLdKLESAOZjuHdLg= diff --git a/interp/api.go b/interp/api.go index 15b8e8eb..8fdda9a3 100644 --- a/interp/api.go +++ b/interp/api.go @@ -26,6 +26,7 @@ import ( "mvdan.cc/sh/v3/expand" "mvdan.cc/sh/v3/syntax" + "github.com/DataDog/datadog-agent/pkg/fleet/installer/telemetry" "github.com/DataDog/rshell/allowedpaths" "github.com/DataDog/rshell/builtins" "github.com/DataDog/rshell/internal/version" @@ -117,6 +118,39 @@ type runnerState struct { stdout io.Writer stderr io.Writer + // runStdin / runStdout are the baselines captured at the start of Run() + // after any Run-level stdout wrapping. Telemetry uses them to decide + // whether a command's stdin/stdout was reassigned by a pipe or redirect. + // Propagated to subshells via the runnerState copy in subshell(). + runStdin *os.File + runStdout io.Writer + + // inPipeline is set to true on subshells created for pipeline stages and + // inherited by further subshells spawned inside them. It suppresses the + // nested rshell.pipeline spans that would otherwise fire when the + // pipeline implementation recurses through stmt() to handle N-stage + // pipelines (a|b|c is BinaryCmd(Pipe, BinaryCmd(Pipe, a, b), c)). Reset + // to false when entering a syntax.Subshell so a pipeline inside (…) gets + // its own span. + inPipeline bool + + // totalCount / dispatchedCount / unallowedCount / unknownCount tally + // the call() invocations this run observed: how many command + // dispatches were attempted in total, how many ran through a + // builtin, how many were blocked by AllowedCommands, and how many + // were not in the builtin registry. The unallowed/unknown pair are + // independent facts about the command name — a command that is both + // blocked and unknown bumps both counters, matching the semantics of + // the per-command rshell.command.is_allowed / is_known tags. + // totalCount counts each call() exactly once regardless of outcome, + // so total_count = dispatched_count + (unallowed-only) + (unknown-only) + // + (unallowed AND unknown). Summed up from subshells when a + // pipeline or (…) completes. + totalCount int + dispatchedCount int + unallowedCount int + unknownCount int + ecfg *expand.Config ectx context.Context // just so that subshell can use it again @@ -469,6 +503,30 @@ func (s ExitStatus) Error() string { return fmt.Sprintf("exit status %d", s) } // incrementally. To reuse a [Runner] without keeping the internal shell state, // call Reset. func (r *Runner) Run(ctx context.Context, node syntax.Node) (retErr error) { + span, ctx := telemetry.StartSpanFromContext(ctx, "run") + span.SetTag("rshell.version", version.Version) + defer func() { + span.SetTag("rshell.run.exit_code", int(r.exit.code)) + span.SetTag("rshell.run.commands.total", r.totalCount) + span.SetTag("rshell.run.commands.dispatched", r.dispatchedCount) + span.SetTag("rshell.run.commands.unallowed", r.unallowedCount) + span.SetTag("rshell.run.commands.unknown", r.unknownCount) + outcome := classifyRunOutcome(retErr) + span.SetTag("rshell.run.outcome", outcome) + // The run span reports whether the shell interpreter did its job — + // any script completion (even with a non-zero last command or an + // explicit `exit N`) is success from that point of view. Only + // abnormal terminations (timeout, canceled, stdout cap hit, + // internal fatal) flag the span as errored. Consumers distinguish + // zero vs non-zero exits via rshell.run.exit_code, and alert on + // policy rejections via rshell.run.unallowed_count > 0. + if outcome == "success" { + span.Finish(nil) + } else { + span.Finish(retErr) + } + }() + defer func() { if rec := recover(); rec != nil { panicOut := io.Writer(io.Discard) @@ -502,6 +560,11 @@ func (r *Runner) Run(ctx context.Context, node syntax.Node) (retErr error) { stdoutCap := &limitWriter{w: prevStdout, limit: maxStdoutBytes} r.stdout = stdoutCap defer func() { r.stdout = prevStdout }() + // Capture the stdin/stdout baseline at Run() start (after wrapping) so + // per-command telemetry can detect pipe and redirect reassignments + // without tripping on the Run-level limitWriter wrap. + r.runStdin = r.stdin + r.runStdout = r.stdout r.startTime = time.Now() r.globReadDirCount = &atomic.Int64{} r.fillExpandConfig(ctx) @@ -678,6 +741,9 @@ func (r *Runner) subshell(background bool) *Runner { stdin: r.stdin, stdout: r.stdout, stderr: r.stderr, + runStdin: r.runStdin, + runStdout: r.runStdout, + inPipeline: r.inPipeline, filename: r.filename, exit: r.exit, lastExit: r.lastExit, @@ -690,3 +756,28 @@ func (r *Runner) subshell(background bool) *Runner { r2.didReset = true return r2 } + +// classifyRunOutcome maps the error Run() is about to return to a stable, +// low-cardinality enum tag on the run span. The outcome answers "did the +// interpreter do its job", not "did the script exit zero" — any script +// completion (including a failing last command or an explicit exit N) is +// "success". Consumers that care about the exit code read +// rshell.run.exit_code; those that care about policy rejections read +// rshell.run.unallowed_count / rshell.run.unknown_count. +func classifyRunOutcome(err error) string { + switch { + case err == nil: + return "success" + case errors.Is(err, ErrOutputLimitExceeded): + return "output_limit" + case errors.Is(err, context.DeadlineExceeded): + return "timeout" + case errors.Is(err, context.Canceled): + return "canceled" + } + var status ExitStatus + if errors.As(err, &status) { + return "success" + } + return "fatal" +} diff --git a/interp/runner_exec.go b/interp/runner_exec.go index 2f858545..aa19a63f 100644 --- a/interp/runner_exec.go +++ b/interp/runner_exec.go @@ -17,6 +17,7 @@ import ( "mvdan.cc/sh/v3/expand" "mvdan.cc/sh/v3/syntax" + "github.com/DataDog/datadog-agent/pkg/fleet/installer/telemetry" "github.com/DataDog/rshell/allowedpaths" "github.com/DataDog/rshell/builtins" ) @@ -61,9 +62,16 @@ func (r *Runner) cmd(ctx context.Context, cm syntax.Command) { switch cm := cm.(type) { case *syntax.Subshell: r2 := r.subshell(false) + // A pipeline inside (…) should get its own pipeline span, so + // clear the flag that suppresses nested pipeline spans. + r2.inPipeline = false r2.stmts(ctx, cm.Stmts) r.exit = r2.exit r.exit.exiting = false + r.totalCount += r2.totalCount + r.dispatchedCount += r2.dispatchedCount + r.unallowedCount += r2.unallowedCount + r.unknownCount += r2.unknownCount case *syntax.Block: r.stmts(ctx, cm.Stmts) case *syntax.CallExpr: @@ -125,6 +133,16 @@ func (r *Runner) cmd(ctx context.Context, cm syntax.Command) { r.stmt(ctx, cm.Y) } case syntax.Pipe: + if !r.inPipeline { + var span *telemetry.Span + span, ctx = telemetry.StartSpanFromContext(ctx, "control_flow") + span.SetResourceName("pipeline") + span.SetTag("rshell.pipeline.stage_count", countPipelineStages(cm)) + defer func() { + span.SetTag("rshell.pipeline.exit_code", int(r.exit.code)) + span.Finish(nil) + }() + } pr, pw, err := os.Pipe() if err != nil { r.exit.fatal(err) // not being able to create a pipe is rare but critical @@ -136,9 +154,11 @@ func (r *Runner) cmd(ctx context.Context, cm syntax.Command) { rLeft := r.subshell(true) rLeft.stdout = pw rLeft.stderr = safeStderr + rLeft.inPipeline = true rRight := r.subshell(true) rRight.stdin = pr rRight.stderr = safeStderr + rRight.inPipeline = true var wg sync.WaitGroup wg.Add(1) go func() { @@ -165,27 +185,34 @@ func (r *Runner) cmd(ctx context.Context, cm syntax.Command) { r.exit.exiting = false pr.Close() wg.Wait() + // Roll each pipeline stage's per-run counters up to the + // parent so the run-span totals reflect commands dispatched, + // blocked, or unknown across every stage. + r.totalCount += rLeft.totalCount + rRight.totalCount + r.dispatchedCount += rLeft.dispatchedCount + rRight.dispatchedCount + r.unallowedCount += rLeft.unallowedCount + rRight.unallowedCount + r.unknownCount += rLeft.unknownCount + rRight.unknownCount if rLeft.exit.fatalExit { r.exit.fatal(rLeft.exit.err) } } case *syntax.IfClause: - r.stmts(ctx, cm.Cond) - if r.exit.exiting || r.breakEnclosing > 0 || r.contnEnclosing > 0 { - break - } - if r.exit.ok() { - r.stmts(ctx, cm.Then) - } else { - r.exit = exitStatus{} - if cm.Else != nil { - r.cmd(ctx, cm.Else) - } - } + r.execIfChain(ctx, cm) case *syntax.ForClause: + span, forCtx := telemetry.StartSpanFromContext(ctx, "control_flow") + span.SetResourceName("for") + iterationCount := 0 + brokeEarly := false + var varName string + defer func() { + span.SetTag("rshell.for.variable_name", varName) + span.SetTag("rshell.for.iteration_count", iterationCount) + span.SetTag("rshell.for.broke_early", brokeEarly) + span.Finish(nil) + }() switch y := cm.Loop.(type) { case *syntax.WordIter: - name := y.Name.Value + varName = y.Name.Value items := r.Params // for i; do ... inToken := y.InPos.IsValid() @@ -194,18 +221,25 @@ func (r *Runner) cmd(ctx context.Context, cm syntax.Command) { } for _, field := range items { - if err := ctx.Err(); err != nil { + if err := forCtx.Err(); err != nil { r.exit.fatal(err) break } - r.setVarString(name, field) - if r.loopStmtsBroken(ctx, cm.Do) { + r.setVarString(varName, field) + iterSpan, iterCtx := telemetry.StartSpanFromContext(forCtx, "control_flow") + iterSpan.SetResourceName("for.iteration") + iterSpan.SetTag("rshell.for.iteration.index", iterationCount) + broken := r.loopStmtsBroken(iterCtx, cm.Do) + iterSpan.Finish(nil) + iterationCount++ + if broken { // Excess continue at outermost loop: clamp and keep iterating // (bash treats "continue 99" in a single loop like "continue 1"). if r.contnEnclosing > 0 && !r.inLoop { r.contnEnclosing = 0 continue } + brokeEarly = true break } } @@ -251,12 +285,47 @@ func (r *Runner) loopStmtsBroken(ctx context.Context, stmts []*syntax.Stmt) bool } func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { + name := args[0] + r.totalCount++ + + // Evaluate both policy checks upfront so the span tags reflect the + // independent facts about the command name regardless of which gate + // short-circuits dispatch. + isAllowed := r.allowAllCommands || r.allowedCommands[name] + fn, isKnown := builtins.Lookup(name) + + span, ctx := telemetry.StartSpanFromContext(ctx, "command") + span.SetResourceName(name) + span.SetTag("rshell.command.name", name) + span.SetTag("rshell.command.argc", len(args)-1) + span.SetTag("rshell.command.is_allowed", isAllowed) + span.SetTag("rshell.command.is_known", isKnown) + // has_stdin_pipe / has_output_redirect reflect whether the command's + // stdin/stdout were reassigned from the Runner's originals — true for + // both pipeline stages and file redirects. + span.SetTag("rshell.command.has_stdin_pipe", r.stdin != r.runStdin) + span.SetTag("rshell.command.has_output_redirect", r.stdout != r.runStdout) + defer func() { + span.SetTag("rshell.command.exit_code", int(r.exit.code)) + span.Finish(nil) + }() + if r.stop(ctx) { return } - name := args[0] - if !r.allowAllCommands && !r.allowedCommands[name] { + // Increment independently — is_allowed and is_known are orthogonal + // facts about the command name, so a command that is both blocked and + // missing from the registry bumps both counters. Mirrors the semantics + // of the per-command rshell.command.is_allowed / is_known tags. + if !isAllowed { + r.unallowedCount++ + } + if !isKnown { + r.unknownCount++ + } + + if !isAllowed { r.errf("rshell: %s: command not allowed\n", name) if r.allowedCommands["help"] { r.errf("Run 'help' to see allowed commands.\n") @@ -265,7 +334,8 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { return } - if fn, ok := builtins.Lookup(name); ok { + if isKnown { + r.dispatchedCount++ var runCmd func(context.Context, string, string, []string) (uint8, error) runCmd = func(ctx context.Context, dir string, cmdName string, cmdArgs []string) (uint8, error) { if !r.allowAllCommands && !r.allowedCommands[cmdName] { @@ -402,6 +472,8 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { r.contnEnclosing = result.ContinueN return } + // Allowed but not known: the default execHandler (noExecHandler) will + // reject with exit 127. unknownCount was already incremented above. r.exec(ctx, pos, args) } @@ -409,6 +481,59 @@ func (r *Runner) exec(ctx context.Context, pos syntax.Pos, args []string) { r.exit.fromHandlerError(r.execHandler(r.handlerCtx(ctx, pos), args)) } +// execIfChain runs an if/elif/else chain iteratively (rather than recursing +// through cmd() on each elif as the AST's Else pointer suggests) so the whole +// chain is covered by a single rshell.if span. The parser encodes "else" as a +// trailing *IfClause with no ThenPos set and an empty Cond. +func (r *Runner) execIfChain(ctx context.Context, cm *syntax.IfClause) { + span, ctx := telemetry.StartSpanFromContext(ctx, "control_flow") + span.SetResourceName("if") + branchCount := 0 + for cur := cm; cur != nil; cur = cur.Else { + branchCount++ + } + branchTaken := -2 // -2 = none matched, -1 = else, >=0 = if/elif index + defer func() { + span.SetTag("rshell.if.branch_taken", branchTaken) + span.SetTag("rshell.if.branch_count", branchCount) + span.Finish(nil) + }() + + idx := 0 + for cur := cm; cur != nil; cur = cur.Else { + // A trailing "else" branch has no "then" token. + if !cur.ThenPos.IsValid() { + branchTaken = -1 + r.stmts(ctx, cur.Then) + return + } + r.stmts(ctx, cur.Cond) + if r.exit.exiting || r.breakEnclosing > 0 || r.contnEnclosing > 0 { + return + } + if r.exit.ok() { + branchTaken = idx + r.stmts(ctx, cur.Then) + return + } + r.exit = exitStatus{} + idx++ + } +} + +// countPipelineStages walks a left-recursive Pipe-BinaryCmd tree (e.g. a|b|c +// is BinaryCmd(Pipe, BinaryCmd(Pipe, a, b), c)) and returns the total number +// of stages. +func countPipelineStages(cm *syntax.BinaryCmd) int { + n := 1 // Y is one stage + if inner, ok := cm.X.Cmd.(*syntax.BinaryCmd); ok && inner.Op == syntax.Pipe { + n += countPipelineStages(inner) + } else { + n += 1 + } + return n +} + // syncWriter wraps an io.Writer with a mutex so concurrent writes are safe. // Used to protect stderr when both sides of a pipe write to it. type syncWriter struct { diff --git a/interp/tracing_test.go b/interp/tracing_test.go new file mode 100644 index 00000000..2a0c8829 --- /dev/null +++ b/interp/tracing_test.go @@ -0,0 +1,603 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package interp + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "sync" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/DataDog/datadog-agent/pkg/fleet/installer/telemetry" + + "github.com/DataDog/rshell/internal/version" +) + +// captureTransport is an http.RoundTripper that records every request body it +// sees and returns a 200 response without making a real network call. It is +// used to intercept payloads the installer telemetry sender would otherwise +// POST to intake. +type captureTransport struct { + mu sync.Mutex + bodies [][]byte +} + +func (c *captureTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if req.Body != nil { + b, _ := io.ReadAll(req.Body) + _ = req.Body.Close() + c.mu.Lock() + c.bodies = append(c.bodies, b) + c.mu.Unlock() + } + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewReader(nil)), + Header: http.Header{}, + Request: req, + }, nil +} + +// newCapturingTelemetry builds a Telemetry instance whose HTTP client points +// at an in-memory RoundTripper, so tests can inspect the payload that would +// otherwise be POSTed to intake without making a real network call. +func newCapturingTelemetry(t *testing.T) (*telemetry.Telemetry, *captureTransport) { + t.Helper() + ct := &captureTransport{} + tel := telemetry.NewTelemetry( + &http.Client{Transport: ct}, + "test-api-key", + "datadoghq.com", + "rshell-test", + ) + return tel, ct +} + +// TestRunEmitsTracerSpan verifies that Run creates a "run" span tagged with +// the rshell version, exit code, and a "success" outcome for a clean run. +func TestRunEmitsTracerSpan(t *testing.T) { + tel, ct := newCapturingTelemetry(t) + + r, err := New(allowAllCommandsOpt()) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + + traceID := newTestTraceID() + require.NoError(t, runWithTracedContext(t, r, traceID, "true")) + tel.Stop() + + spans := ct.spansForTrace(t, traceID) + runSpan := findOneSpanByResource(spans, "run") + require.NotNil(t, runSpan, "expected a run span") + assert.Equal(t, version.Version, runSpan.Meta["rshell.version"]) + assert.Equal(t, "success", runSpan.Meta["rshell.run.outcome"]) + assert.Equal(t, float64(0), runSpan.Metrics["rshell.run.exit_code"]) +} + +// TestRunSpanOutcome verifies the outcome classification on the run span: +// any script completion (zero exit, non-zero exit, explicit exit N, or +// scripts that hit blocked/unknown commands along the way) is "success" +// — rshell treats these as the shell doing its job. The exit code lands +// in rshell.run.exit_code; blocked/unknown dispatches are counted via +// rshell.run.unallowed_count and rshell.run.unknown_count. +func TestRunSpanOutcome(t *testing.T) { + cases := []struct { + name string + script string + opts []RunnerOption + outcome string + exitCode float64 + }{ + {"natural exit 0", "true", + []RunnerOption{allowAllCommandsOpt()}, + "success", 0}, + {"explicit exit 0", "exit 0", + []RunnerOption{allowAllCommandsOpt()}, + "success", 0}, + {"last command returns non-zero", "false", + []RunnerOption{allowAllCommandsOpt()}, + "success", 1}, + {"explicit exit N", "exit 7", + []RunnerOption{allowAllCommandsOpt()}, + "success", 7}, + {"blocked command on the way", "cat /etc/hostname; echo ok", + []RunnerOption{AllowedCommands([]string{"rshell:echo"}), StdIO(nil, io.Discard, io.Discard)}, + "success", 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tel, ct := newCapturingTelemetry(t) + r, err := New(tc.opts...) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + + traceID := newTestTraceID() + _ = runWithTracedContext(t, r, traceID, tc.script) + tel.Stop() + + spans := ct.spansForTrace(t, traceID) + runSpan := findOneSpanByResource(spans, "run") + require.NotNil(t, runSpan) + assert.Equal(t, tc.outcome, runSpan.Meta["rshell.run.outcome"]) + assert.Equal(t, tc.exitCode, runSpan.Metrics["rshell.run.exit_code"]) + assert.Empty(t, runSpan.Meta["error.message"], + "script completion should not flag the run span as errored") + }) + } +} + +// TestRunSpanPolicyCounters verifies the three per-run counters on the +// run span — dispatched_count (commands that actually ran a builtin), +// unallowed_count (blocked by AllowedCommands), and unknown_count +// (missing from the builtin registry) — tally correctly, including +// across pipeline stages and for commands that are both blocked and +// unknown (which bump both counters since is_allowed and is_known are +// independent facts about the command name). +func TestRunSpanPolicyCounters(t *testing.T) { + cases := []struct { + name string + script string + opts []RunnerOption + totalCount float64 + dispatchedCount float64 + unallowedCount float64 + unknownCount float64 + }{ + {"no rejections", "echo a; echo b", + []RunnerOption{allowAllCommandsOpt(), StdIO(nil, io.Discard, io.Discard)}, + 2, 2, 0, 0}, + {"one blocked, one dispatched", "echo a; cat /etc/hostname", + []RunnerOption{AllowedCommands([]string{"rshell:echo"}), StdIO(nil, io.Discard, io.Discard)}, + 2, 1, 1, 0}, + {"blocked builtins across pipeline stages", + "cat x; cat y | grep z", + []RunnerOption{AllowedCommands([]string{"rshell:echo"}), StdIO(nil, io.Discard, io.Discard)}, + 3, 0, 3, 0}, + {"one unknown", "echo a; nosuchcmd_xyz", + []RunnerOption{allowAllCommandsOpt(), StdIO(nil, io.Discard, io.Discard)}, + 2, 1, 0, 1}, + {"blocked and unknown simultaneously (pwd-like)", + "echo a; pwd_no_such", + []RunnerOption{AllowedCommands([]string{"rshell:echo"}), StdIO(nil, io.Discard, io.Discard)}, + 2, 1, 1, 1}, + {"blocked builtin plus allowed-but-unknown", + "cat x; totally_made_up", + []RunnerOption{AllowedCommands([]string{"rshell:totally_made_up"}), StdIO(nil, io.Discard, io.Discard)}, + 2, 0, 1, 1}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tel, ct := newCapturingTelemetry(t) + r, err := New(tc.opts...) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + + traceID := newTestTraceID() + _ = runWithTracedContext(t, r, traceID, tc.script) + tel.Stop() + + spans := ct.spansForTrace(t, traceID) + runSpan := findOneSpanByResource(spans, "run") + require.NotNil(t, runSpan) + assert.Equal(t, tc.totalCount, runSpan.Metrics["rshell.run.commands.total"]) + assert.Equal(t, tc.dispatchedCount, runSpan.Metrics["rshell.run.commands.dispatched"]) + assert.Equal(t, tc.unallowedCount, runSpan.Metrics["rshell.run.commands.unallowed"]) + assert.Equal(t, tc.unknownCount, runSpan.Metrics["rshell.run.commands.unknown"]) + // These are accounting tags, not error signals. + assert.Equal(t, "success", runSpan.Meta["rshell.run.outcome"]) + assert.Empty(t, runSpan.Meta["error.message"]) + }) + } +} + +// decodedEvent mirrors the top-level JSON shape the installer telemetry sender +// POSTs to intake. Only the fields needed by tests are listed. +type decodedEvent struct { + RequestType string `json:"request_type"` + Payload json.RawMessage `json:"payload"` +} + +type decodedTracePayload struct { + Traces [][]decodedSpan `json:"traces"` +} + +type decodedSpan struct { + Name string `json:"name"` + Resource string `json:"resource"` + TraceID uint64 `json:"trace_id"` + SpanID uint64 `json:"span_id"` + ParentID uint64 `json:"parent_id"` + Meta map[string]string `json:"meta"` + Metrics map[string]float64 `json:"metrics"` +} + +// spansForTrace decodes every captured request body and returns the spans that +// belong to the given trace ID. Filtering by trace ID isolates spans produced +// by a single test from any leftover spans that accumulated on the +// package-level global tracer across other tests. +func (c *captureTransport) spansForTrace(t *testing.T, traceID uint64) []decodedSpan { + t.Helper() + c.mu.Lock() + defer c.mu.Unlock() + var spans []decodedSpan + for _, body := range c.bodies { + var evt decodedEvent + require.NoError(t, json.Unmarshal(body, &evt)) + if evt.RequestType != "traces" { + continue + } + var pl decodedTracePayload + require.NoError(t, json.Unmarshal(evt.Payload, &pl)) + for _, trace := range pl.Traces { + for _, sp := range trace { + if sp.TraceID == traceID { + spans = append(spans, sp) + } + } + } + } + return spans +} + +// findSpanByCommand looks up a command span (operation "command") whose +// resource matches cmdName. The rshell.command.name tag is cross-checked to +// guard against a non-command span having a coincidentally matching resource. +func findSpanByCommand(spans []decodedSpan, cmdName string) *decodedSpan { + for i := range spans { + if spans[i].Name == "command" && spans[i].Resource == cmdName && + spans[i].Meta["rshell.command.name"] == cmdName { + return &spans[i] + } + } + return nil +} + +// findSpansByResource returns every span whose resource equals the given +// value. For control_flow spans (if/for/pipeline/for.iteration) and the run +// span, resource is the canonical identity of the span kind — operation name +// is shared across kinds (e.g. all control_flow spans share operation +// "control_flow"). +func findSpansByResource(spans []decodedSpan, resource string) []decodedSpan { + var out []decodedSpan + for _, sp := range spans { + if sp.Resource == resource { + out = append(out, sp) + } + } + return out +} + +func findOneSpanByResource(spans []decodedSpan, resource string) *decodedSpan { + matches := findSpansByResource(spans, resource) + if len(matches) == 0 { + return nil + } + return &matches[0] +} + +// testTraceIDCounter gives each test a unique, non-zero trace ID so the +// captured payload can be filtered regardless of what else accumulated on the +// shared global tracer before the test ran. +var testTraceIDCounter atomic.Uint64 + +func newTestTraceID() uint64 { return testTraceIDCounter.Add(1) + 1 } + +// runWithTracedContext runs prog on r under a context that carries a parent +// span with the given trace ID, then finishes the parent. Any rshell spans +// created during the run inherit the trace ID, which is what the test filters +// on. +func runWithTracedContext(t *testing.T, r *Runner, traceID uint64, script string) error { + t.Helper() + parent, ctx := telemetry.StartSpanFromIDs(context.Background(), "test.parent", + uint64ToDec(traceID), "0") + err := r.Run(ctx, parseScript(t, script)) + parent.Finish(nil) + return err +} + +func uint64ToDec(v uint64) string { + // strconv would work too — small helper to avoid the extra import. + buf := make([]byte, 0, 20) + if v == 0 { + return "0" + } + for v > 0 { + buf = append([]byte{byte('0' + v%10)}, buf...) + v /= 10 + } + return string(buf) +} + +// TestCallEmitsCommandSpan verifies that invoking a command emits an +// "command" span tagged with the command name. +func TestCallEmitsCommandSpan(t *testing.T) { + tel, ct := newCapturingTelemetry(t) + + r, err := New(allowAllCommandsOpt()) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + + traceID := newTestTraceID() + require.NoError(t, runWithTracedContext(t, r, traceID, "true")) + tel.Stop() + + spans := ct.spansForTrace(t, traceID) + cmd := findSpanByCommand(spans, "true") + require.NotNil(t, cmd, "expected a command span for 'true'") + assert.Equal(t, "true", cmd.Meta["rshell.command.name"]) + assert.Equal(t, "true", cmd.Meta["rshell.command.is_allowed"]) + assert.Equal(t, "true", cmd.Meta["rshell.command.is_known"]) + assert.Equal(t, "false", cmd.Meta["rshell.command.has_stdin_pipe"]) + assert.Equal(t, "false", cmd.Meta["rshell.command.has_output_redirect"]) + assert.Equal(t, float64(0), cmd.Metrics["rshell.command.argc"]) + assert.Equal(t, float64(0), cmd.Metrics["rshell.command.exit_code"]) +} + +// TestCommandSpanArgc verifies argc reflects the number of arguments passed +// (not counting argv[0]). +func TestCommandSpanArgc(t *testing.T) { + tel, ct := newCapturingTelemetry(t) + + r, err := New(allowAllCommandsOpt(), StdIO(nil, io.Discard, io.Discard)) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + + traceID := newTestTraceID() + require.NoError(t, runWithTracedContext(t, r, traceID, "echo a b c")) + tel.Stop() + + spans := ct.spansForTrace(t, traceID) + cmd := findSpanByCommand(spans, "echo") + require.NotNil(t, cmd) + assert.Equal(t, float64(3), cmd.Metrics["rshell.command.argc"]) +} + +// TestCommandSpanDisallowed verifies that a command blocked by AllowedCommands +// is captured with is_allowed=false and exit_code=127, and that the is_known +// tag is still populated regardless of the short-circuit. +func TestCommandSpanDisallowed(t *testing.T) { + tel, ct := newCapturingTelemetry(t) + + // Allow echo only; cat is a known builtin but not allowed. + r, err := New(AllowedCommands([]string{"rshell:echo"}), StdIO(nil, io.Discard, io.Discard)) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + + traceID := newTestTraceID() + _ = runWithTracedContext(t, r, traceID, "cat /etc/hostname") + tel.Stop() + + spans := ct.spansForTrace(t, traceID) + cmd := findSpanByCommand(spans, "cat") + require.NotNil(t, cmd, "expected a command span for the blocked call") + assert.Equal(t, "false", cmd.Meta["rshell.command.is_allowed"]) + assert.Equal(t, "true", cmd.Meta["rshell.command.is_known"]) + assert.Equal(t, float64(127), cmd.Metrics["rshell.command.exit_code"]) +} + +// TestCommandSpanUnknown verifies that a command not in the builtin registry +// is captured with is_known=false, and (under --allow-all-commands) also +// carries is_allowed=true with exit_code=127 from the noExecHandler. +func TestCommandSpanUnknown(t *testing.T) { + tel, ct := newCapturingTelemetry(t) + + r, err := New(allowAllCommandsOpt(), StdIO(nil, io.Discard, io.Discard)) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + + traceID := newTestTraceID() + _ = runWithTracedContext(t, r, traceID, "definitely_not_a_real_command") + tel.Stop() + + spans := ct.spansForTrace(t, traceID) + cmd := findSpanByCommand(spans, "definitely_not_a_real_command") + require.NotNil(t, cmd) + assert.Equal(t, "true", cmd.Meta["rshell.command.is_allowed"]) + assert.Equal(t, "false", cmd.Meta["rshell.command.is_known"]) + assert.Equal(t, float64(127), cmd.Metrics["rshell.command.exit_code"]) +} + +// TestCommandSpanNonZeroExit verifies the exit_code tag captures the actual +// exit status returned by a builtin (here, the "false" builtin returns 1). +func TestCommandSpanNonZeroExit(t *testing.T) { + tel, ct := newCapturingTelemetry(t) + + r, err := New(allowAllCommandsOpt()) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + + traceID := newTestTraceID() + _ = runWithTracedContext(t, r, traceID, "false") + tel.Stop() + + spans := ct.spansForTrace(t, traceID) + cmd := findSpanByCommand(spans, "false") + require.NotNil(t, cmd) + assert.Equal(t, float64(1), cmd.Metrics["rshell.command.exit_code"]) +} + +// TestCommandSpanPipeline verifies that the left stage of a pipeline has +// has_output_redirect=true (its stdout points at the pipe write end) while the +// right stage has has_stdin_pipe=true (its stdin points at the pipe read end). +func TestCommandSpanPipeline(t *testing.T) { + tel, ct := newCapturingTelemetry(t) + + var outBuf bytes.Buffer + r, err := New(allowAllCommandsOpt(), StdIO(nil, &outBuf, io.Discard)) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + + traceID := newTestTraceID() + require.NoError(t, runWithTracedContext(t, r, traceID, "echo hi | cat")) + tel.Stop() + + spans := ct.spansForTrace(t, traceID) + echo := findSpanByCommand(spans, "echo") + cat := findSpanByCommand(spans, "cat") + require.NotNil(t, echo, "expected echo span") + require.NotNil(t, cat, "expected cat span") + + assert.Equal(t, "false", echo.Meta["rshell.command.has_stdin_pipe"]) + assert.Equal(t, "true", echo.Meta["rshell.command.has_output_redirect"]) + assert.Equal(t, "true", cat.Meta["rshell.command.has_stdin_pipe"]) + assert.Equal(t, "false", cat.Meta["rshell.command.has_output_redirect"]) +} + +// TestPipelineSpan verifies that a 3-stage pipeline produces a single +// flattened pipeline span with stage_count=3 and exit_code from the +// rightmost stage. +func TestPipelineSpan(t *testing.T) { + tel, ct := newCapturingTelemetry(t) + + var outBuf bytes.Buffer + r, err := New(allowAllCommandsOpt(), StdIO(nil, &outBuf, io.Discard)) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + + traceID := newTestTraceID() + require.NoError(t, runWithTracedContext(t, r, traceID, "echo hi | cat | cat")) + tel.Stop() + + spans := ct.spansForTrace(t, traceID) + pipeSpans := findSpansByResource(spans, "pipeline") + require.Len(t, pipeSpans, 1, "expected exactly one pipeline span (flattened)") + assert.Equal(t, float64(3), pipeSpans[0].Metrics["rshell.pipeline.stage_count"]) + assert.Equal(t, float64(0), pipeSpans[0].Metrics["rshell.pipeline.exit_code"]) +} + +// TestIfSpanBranchTaken covers each possible branch_taken value on a single +// chain shape (if/elif/else): primary, elif, else, and none. +func TestIfSpanBranchTaken(t *testing.T) { + cases := []struct { + name string + script string + count int // expected branch_count + taken float64 // expected branch_taken + hasElse bool + }{ + {"primary", "if true; then true; elif true; then true; else true; fi", 3, 0, true}, + {"elif", "if false; then true; elif true; then true; else true; fi", 3, 1, true}, + {"else", "if false; then true; elif false; then true; else true; fi", 3, -1, true}, + {"none", "if false; then true; elif false; then true; fi", 2, -2, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tel, ct := newCapturingTelemetry(t) + r, err := New(allowAllCommandsOpt()) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + + traceID := newTestTraceID() + require.NoError(t, runWithTracedContext(t, r, traceID, tc.script)) + tel.Stop() + + spans := ct.spansForTrace(t, traceID) + ifSpan := findOneSpanByResource(spans, "if") + require.NotNil(t, ifSpan, "expected one if span") + assert.Equal(t, float64(tc.count), ifSpan.Metrics["rshell.if.branch_count"]) + assert.Equal(t, tc.taken, ifSpan.Metrics["rshell.if.branch_taken"]) + }) + } +} + +// TestForSpan verifies for-span tags and that each iteration emits a +// rshell.for.iteration span with the expected zero-based index. +func TestForSpan(t *testing.T) { + tel, ct := newCapturingTelemetry(t) + + r, err := New(allowAllCommandsOpt()) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + + traceID := newTestTraceID() + require.NoError(t, runWithTracedContext(t, r, traceID, "for x in a b c; do true; done")) + tel.Stop() + + spans := ct.spansForTrace(t, traceID) + forSpan := findOneSpanByResource(spans, "for") + require.NotNil(t, forSpan) + assert.Equal(t, "x", forSpan.Meta["rshell.for.variable_name"]) + assert.Equal(t, float64(3), forSpan.Metrics["rshell.for.iteration_count"]) + assert.Equal(t, "false", forSpan.Meta["rshell.for.broke_early"]) + + iters := findSpansByResource(spans, "for.iteration") + require.Len(t, iters, 3) + indexes := make(map[float64]bool) + for _, it := range iters { + indexes[it.Metrics["rshell.for.iteration.index"]] = true + } + assert.True(t, indexes[0] && indexes[1] && indexes[2], + "expected iteration indexes 0, 1, 2; got %v", indexes) +} + +// TestForSpanBrokeEarly verifies broke_early is true when the loop exits via +// an explicit break, and iteration_count reflects how many iterations ran +// before the break. +func TestForSpanBrokeEarly(t *testing.T) { + tel, ct := newCapturingTelemetry(t) + + r, err := New(allowAllCommandsOpt()) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + + traceID := newTestTraceID() + require.NoError(t, runWithTracedContext(t, r, traceID, + "for x in a b c d; do if [ \"$x\" = b ]; then break; fi; done")) + tel.Stop() + + spans := ct.spansForTrace(t, traceID) + forSpan := findOneSpanByResource(spans, "for") + require.NotNil(t, forSpan) + assert.Equal(t, "true", forSpan.Meta["rshell.for.broke_early"]) + // Two iterations ran: x=a (body continues) and x=b (break taken). + assert.Equal(t, float64(2), forSpan.Metrics["rshell.for.iteration_count"]) +} + +// TestNesting verifies that for → if → pipeline produces nested spans linked +// via ParentID, so the trace view reflects the control-flow structure. +func TestNesting(t *testing.T) { + tel, ct := newCapturingTelemetry(t) + + var outBuf bytes.Buffer + r, err := New(allowAllCommandsOpt(), StdIO(nil, &outBuf, io.Discard)) + require.NoError(t, err) + t.Cleanup(func() { r.Close() }) + + traceID := newTestTraceID() + script := "for i in a; do if true; then echo hi | cat; fi; done" + require.NoError(t, runWithTracedContext(t, r, traceID, script)) + tel.Stop() + + spans := ct.spansForTrace(t, traceID) + + forSpan := findOneSpanByResource(spans, "for") + iterSpan := findOneSpanByResource(spans, "for.iteration") + ifSpan := findOneSpanByResource(spans, "if") + pipeSpan := findOneSpanByResource(spans, "pipeline") + echoSpan := findSpanByCommand(spans, "echo") + catSpan := findSpanByCommand(spans, "cat") + + require.NotNil(t, forSpan, "expected for span") + require.NotNil(t, iterSpan, "expected for.iteration span") + require.NotNil(t, ifSpan, "expected if span") + require.NotNil(t, pipeSpan, "expected pipeline span") + require.NotNil(t, echoSpan, "expected echo command span") + require.NotNil(t, catSpan, "expected cat command span") + + // Walk the expected parent chain: echo/cat → pipeline → if → iteration → for. + assert.Equal(t, pipeSpan.SpanID, echoSpan.ParentID, "echo's parent should be the pipeline span") + assert.Equal(t, pipeSpan.SpanID, catSpan.ParentID, "cat's parent should be the pipeline span") + assert.Equal(t, ifSpan.SpanID, pipeSpan.ParentID, "pipeline's parent should be the if span") + assert.Equal(t, iterSpan.SpanID, ifSpan.ParentID, "if's parent should be the for.iteration span") + assert.Equal(t, forSpan.SpanID, iterSpan.ParentID, "for.iteration's parent should be the for span") +} diff --git a/tests/scenarios/shell/if_clause/conditions/multi_command_cond_last_wins.yaml b/tests/scenarios/shell/if_clause/conditions/multi_command_cond_last_wins.yaml new file mode 100644 index 00000000..495cf3ee --- /dev/null +++ b/tests/scenarios/shell/if_clause/conditions/multi_command_cond_last_wins.yaml @@ -0,0 +1,19 @@ +description: When an if condition contains multiple commands, only the last command's exit status determines which branch runs. +input: + script: |+ + if false; true; then + echo then-taken + else + echo else-taken + fi + if true; false; then + echo second-then + else + echo second-else + fi +expect: + stdout: |+ + then-taken + second-else + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/if_clause/conditions/side_effect_short_circuit.yaml b/tests/scenarios/shell/if_clause/conditions/side_effect_short_circuit.yaml new file mode 100644 index 00000000..f0650a76 --- /dev/null +++ b/tests/scenarios/shell/if_clause/conditions/side_effect_short_circuit.yaml @@ -0,0 +1,17 @@ +description: Once a branch matches, later elif conditions must not run (short-circuit). Side effects in later conditions must be invisible. +input: + script: |+ + if true; then + echo matched-first + elif echo should-not-run; then + echo elif-body + elif echo also-should-not-run; then + echo second-elif-body + else + echo else-body + fi +expect: + stdout: |+ + matched-first + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/if_clause/edge_cases/assignment_in_condition.yaml b/tests/scenarios/shell/if_clause/edge_cases/assignment_in_condition.yaml new file mode 100644 index 00000000..c55198ff --- /dev/null +++ b/tests/scenarios/shell/if_clause/edge_cases/assignment_in_condition.yaml @@ -0,0 +1,13 @@ +description: A variable assigned in an if condition is visible in the matched branch body and after the if. +input: + script: |+ + if x=42; then + echo inside=$x + fi + echo after=$x +expect: + stdout: |+ + inside=42 + after=42 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/if_clause/edge_cases/deep_elif_chain.yaml b/tests/scenarios/shell/if_clause/edge_cases/deep_elif_chain.yaml new file mode 100644 index 00000000..84b82195 --- /dev/null +++ b/tests/scenarios/shell/if_clause/edge_cases/deep_elif_chain.yaml @@ -0,0 +1,17 @@ +description: Deep 8-way elif chain with only the seventh condition true exercises the iterative chain walk. +input: + script: |+ + if false; then echo 1 + elif false; then echo 2 + elif false; then echo 3 + elif false; then echo 4 + elif false; then echo 5 + elif false; then echo 6 + elif true; then echo 7 + else echo 8 + fi +expect: + stdout: |+ + 7 + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/if_clause/edge_cases/exit_in_elif_condition.yaml b/tests/scenarios/shell/if_clause/edge_cases/exit_in_elif_condition.yaml new file mode 100644 index 00000000..693bff93 --- /dev/null +++ b/tests/scenarios/shell/if_clause/edge_cases/exit_in_elif_condition.yaml @@ -0,0 +1,15 @@ +description: An "exit" command inside an elif condition (reached mid-chain) terminates the script before the matching branch would be chosen. +input: + script: |+ + if false; then + echo first + elif exit 9; then + echo elif-body + else + echo else-body + fi + echo unreachable +expect: + stdout: "" + stderr: "" + exit_code: 9