From ac28710cb2aee5ed5e000509be68cedfea0edd00 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 24 Apr 2026 17:09:33 -0700 Subject: [PATCH 1/4] feat(snippets): add sdk-snippets generator for python-server-sdk getting-started MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces sdk-meta/snippets/, a self-contained Go module that owns the canonical source for LaunchDarkly SDK code samples and renders them into downstream consumers. Slice scope: python-server-sdk "Getting Started" flow, end-to-end. What's here ----------- - cmd/snippets — CLI with three subcommands: render — rewrite the body of every JSX element marked with an SDK_SNIPPET:RENDER comment in a consumer checkout verify — recompute hashes and fail if any managed region drifted validate — run each snippet inside a per-language Docker validator - internal/model — snippet file format (YAML frontmatter + one fenced code block) loaded from sdks//snippets/. - internal/render — minimal {{ var }} / {{ if var }}...{{ end }} templating engine with a runtime mode for validation and a JS template-literal mode for the ld-application adapter. - internal/markers — host-syntax-aware scanner for SDK_SNIPPET:RENDER comments (`// …`, `{/* … */}`, `/* … */`); hashes the rendered region to catch hand-edits. - internal/adapters/ldapplication — first adapter target. Discovers consumer files via each sdk.yaml's ld-application.get-started-file field and rewrites only the JSX children of marked elements, preserving surrounding whitespace. - internal/validate — orchestrator that builds a Docker image per language and runs the snippet verbatim against a real LaunchDarkly environment. The SDK key and flag key come from the caller's LAUNCHDARKLY_SDK_KEY / LAUNCHDARKLY_FLAG_KEY env vars (the same convention the hello-* sample apps use), are forwarded into the container, and never end up in committed files. - sdks/python-server-sdk — sdk descriptor plus four snippets sourced verbatim from the existing get-started flow: getting-started/mkdir getting-started/install getting-started/main-py getting-started/run - validators/languages/python — Docker image plus run.sh that pip- installs the snippet's own requirements, runs the entrypoint, and matches the expected flag-evaluation line within a timeout. - docs/AUTHORING.md — snippet authoring guide. Naming and module path ---------------------- - Go module: github.com/launchdarkly/sdk-meta/snippets - Adapter target: --target=ld-application (renders into the LD application UI). A future --target=ld-docs adapter is planned for the docs site; not implemented yet. Out of scope (kept in design doc, not in this commit): all SDKs other than python-server-sdk, the ld-docs MDX adapter, GitHub Actions, signed-binary release pipeline, sdk-meta capability integration, region/version conditional rendering. Verified locally ---------------- - go build ./... : clean - go test ./... : render + markers tests pass - snippets render --target=ld-application --out=: idempotent - snippets verify --target=ld-application --out=: ok - snippets validate (with LAUNCHDARKLY_SDK_KEY + LAUNCHDARKLY_FLAG_KEY): matches "*** The feature flag evaluates to ..." against a real LD environment. --- snippets/.gitignore | 5 + snippets/README.md | 50 ++++ snippets/cmd/snippets/main.go | 105 +++++++ snippets/docs/AUTHORING.md | 79 +++++ snippets/go.mod | 5 + snippets/go.sum | 4 + .../adapters/ldapplication/descriptor.go | 28 ++ .../adapters/ldapplication/ldapplication.go | 209 +++++++++++++ snippets/internal/markers/markers.go | 280 ++++++++++++++++++ snippets/internal/markers/markers_test.go | 56 ++++ snippets/internal/model/model.go | 152 ++++++++++ snippets/internal/render/render.go | 74 +++++ snippets/internal/render/template.go | 85 ++++++ snippets/internal/render/template_test.go | 46 +++ snippets/internal/validate/validate.go | 148 +++++++++ snippets/internal/version/version.go | 5 + snippets/sdks/python-server-sdk/sdk.yaml | 14 + .../getting-started/install.snippet.md | 20 ++ .../getting-started/main-py.snippet.md | 101 +++++++ .../snippets/getting-started/mkdir.snippet.md | 15 + .../snippets/getting-started/run.snippet.md | 19 ++ .../validators/languages/python/Dockerfile | 7 + .../languages/python/harness/run.sh | 57 ++++ 23 files changed, 1564 insertions(+) create mode 100644 snippets/.gitignore create mode 100644 snippets/README.md create mode 100644 snippets/cmd/snippets/main.go create mode 100644 snippets/docs/AUTHORING.md create mode 100644 snippets/go.mod create mode 100644 snippets/go.sum create mode 100644 snippets/internal/adapters/ldapplication/descriptor.go create mode 100644 snippets/internal/adapters/ldapplication/ldapplication.go create mode 100644 snippets/internal/markers/markers.go create mode 100644 snippets/internal/markers/markers_test.go create mode 100644 snippets/internal/model/model.go create mode 100644 snippets/internal/render/render.go create mode 100644 snippets/internal/render/template.go create mode 100644 snippets/internal/render/template_test.go create mode 100644 snippets/internal/validate/validate.go create mode 100644 snippets/internal/version/version.go create mode 100644 snippets/sdks/python-server-sdk/sdk.yaml create mode 100644 snippets/sdks/python-server-sdk/snippets/getting-started/install.snippet.md create mode 100644 snippets/sdks/python-server-sdk/snippets/getting-started/main-py.snippet.md create mode 100644 snippets/sdks/python-server-sdk/snippets/getting-started/mkdir.snippet.md create mode 100644 snippets/sdks/python-server-sdk/snippets/getting-started/run.snippet.md create mode 100644 snippets/validators/languages/python/Dockerfile create mode 100755 snippets/validators/languages/python/harness/run.sh diff --git a/snippets/.gitignore b/snippets/.gitignore new file mode 100644 index 0000000..3bd25cd --- /dev/null +++ b/snippets/.gitignore @@ -0,0 +1,5 @@ +/snippets +/dist +.cache/ +*.test +.DS_Store diff --git a/snippets/README.md b/snippets/README.md new file mode 100644 index 0000000..e4f5640 --- /dev/null +++ b/snippets/README.md @@ -0,0 +1,50 @@ +# sdk-snippets + +Single source of truth for LaunchDarkly SDK code snippets rendered into the +LD application UI and the LD docs site. + +First slice: `python-server-sdk` "Getting started" flow. + +## Layout + +``` +cmd/snippets/ CLI entrypoint (render, verify, validate) +internal/ generator Go code +sdks// one directory per SDK + sdk.yaml SDK descriptor + snippets/ .snippet.md files (YAML frontmatter + Markdown + code blocks) +validators/languages/ per-language Docker validator harnesses +``` + +## CLI + +``` +snippets render --target=ld-application --out= +snippets verify --target=ld-application --out= +snippets validate --sdk=python-server-sdk +``` + +`render` rewrites the code regions between `SDK_SNIPPET:RENDER` markers. +`verify` recomputes hashes and fails if any managed region has drifted. +`validate` runs each snippet in a Docker container against a real LaunchDarkly +environment. Required env vars (matching the convention used by the `hello-*` +sample apps): + +```sh +export LAUNCHDARKLY_SDK_KEY= +export LAUNCHDARKLY_FLAG_KEY= +snippets validate --sdk=python-server-sdk +``` + +Neither variable is committed to this repo. They are forwarded into the per- +snippet Docker run. + +## Adapter targets + +| Target | Renders into | +|---|---| +| `ld-application` | the LaunchDarkly application's "Get Started" flows (TSX/JSX) | +| `ld-docs` | the LaunchDarkly documentation site (MDX) — not yet implemented | + +The adapter is selected via `--target`. Today only `ld-application` is wired +up; `ld-docs` will land in a later slice. diff --git a/snippets/cmd/snippets/main.go b/snippets/cmd/snippets/main.go new file mode 100644 index 0000000..d163af4 --- /dev/null +++ b/snippets/cmd/snippets/main.go @@ -0,0 +1,105 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/launchdarkly/sdk-meta/snippets/internal/adapters/ldapplication" + "github.com/launchdarkly/sdk-meta/snippets/internal/validate" + "github.com/launchdarkly/sdk-meta/snippets/internal/version" +) + +const usage = `snippets — LaunchDarkly SDK snippet generator + +usage: + snippets render --target=ld-application --out= [--sdks=./sdks] + snippets verify --target=ld-application --out= [--sdks=./sdks] + snippets validate --sdk= [--sdks=./sdks] [--validators=./validators] + snippets version + +First-pass support: target=ld-application, sdk=python-server-sdk. +` + +func main() { + if len(os.Args) < 2 { + fmt.Fprint(os.Stderr, usage) + os.Exit(2) + } + switch os.Args[1] { + case "render": + runRender(os.Args[2:]) + case "verify": + runVerify(os.Args[2:]) + case "validate": + runValidate(os.Args[2:]) + case "version": + fmt.Println(version.Version) + case "-h", "--help", "help": + fmt.Print(usage) + default: + fmt.Fprintf(os.Stderr, "unknown subcommand %q\n\n%s", os.Args[1], usage) + os.Exit(2) + } +} + +func runRender(args []string) { + fs := flag.NewFlagSet("render", flag.ExitOnError) + target := fs.String("target", "", "adapter target: `ld-application`") + out := fs.String("out", "", "path to the consumer checkout") + sdks := fs.String("sdks", "./sdks", "path to the sdks/ directory") + _ = fs.Parse(args) + + if *target != "ld-application" || *out == "" { + fmt.Fprintf(os.Stderr, "render: --target=ld-application and --out are required\n") + os.Exit(2) + } + changed, err := ldapplication.Render(*sdks, *out) + if err != nil { + fmt.Fprintf(os.Stderr, "render failed: %v\n", err) + os.Exit(1) + } + if len(changed) == 0 { + fmt.Println("no changes") + return + } + for _, p := range changed { + fmt.Println("rewrote", p) + } +} + +func runVerify(args []string) { + fs := flag.NewFlagSet("verify", flag.ExitOnError) + target := fs.String("target", "", "adapter target: `ld-application`") + out := fs.String("out", "", "path to the consumer checkout") + sdks := fs.String("sdks", "./sdks", "path to the sdks/ directory") + _ = fs.Parse(args) + + if *target != "ld-application" || *out == "" { + fmt.Fprintf(os.Stderr, "verify: --target=ld-application and --out are required\n") + os.Exit(2) + } + if err := ldapplication.Verify(*sdks, *out); err != nil { + fmt.Fprintf(os.Stderr, "verify failed: %v\n", err) + os.Exit(1) + } + fmt.Println("ok") +} + +func runValidate(args []string) { + fs := flag.NewFlagSet("validate", flag.ExitOnError) + sdk := fs.String("sdk", "", "sdk id to validate (required)") + sdks := fs.String("sdks", "./sdks", "path to the sdks/ directory") + validators := fs.String("validators", "./validators", "path to the validators/ directory") + _ = fs.Parse(args) + + if *sdk == "" { + fmt.Fprintf(os.Stderr, "validate: --sdk is required\n") + os.Exit(2) + } + if err := validate.Run(validate.Config{SDKsDir: *sdks, ValidatorsDir: *validators, SDK: *sdk}); err != nil { + fmt.Fprintf(os.Stderr, "validate failed: %v\n", err) + os.Exit(1) + } + fmt.Println("ok") +} diff --git a/snippets/docs/AUTHORING.md b/snippets/docs/AUTHORING.md new file mode 100644 index 0000000..f17fb49 --- /dev/null +++ b/snippets/docs/AUTHORING.md @@ -0,0 +1,79 @@ +# Authoring snippets + +A snippet is a single Markdown file with YAML frontmatter and one fenced code +block. See `sdks/python-server-sdk/snippets/getting-started/` for the canonical +examples. + +## Frontmatter fields used in the first slice + +| Field | Required | Notes | +|---|---|---| +| `id` | yes | globally unique; convention `//` | +| `sdk` | yes | matches a directory under `sdks/` | +| `kind` | yes | `bootstrap`, `install`, `hello-world`, `run` | +| `lang` | yes | language tag for the fenced code block | +| `description` | recommended | one-liner shown to the consumer | +| `inputs` | optional | each input has `type`, `description`, `runtime-default` | +| `ld-application.slot` | optional | logical slot label for the ld-application adapter | +| `validation.entrypoint` | optional | filename to run inside the validator (e.g. `main.py`) | +| `validation.requirements` | optional | line written into `requirements.txt` | + +## Templating + +Inside the code block: + +- `{{ name }}` — substitutes an input value +- `{{ if name }}...{{ end }}` — emits the inner content only when `name` has a non-empty value + +Conditionals do not nest. The inner content of a conditional may still contain `{{ name }}`. + +## Render modes + +Same template renders three different ways: + +| Mode | What it produces | +|---|---| +| `ld-application` | JS template-literal expression with `${name}` interpolations and `${name ? \`...\` : ''}` ternaries inside JSX. | +| `runtime` (validator) | Plain code with concrete values substituted. | +| `verify` | Same as `ld-application`, then compared byte-for-byte against the consumer file. | + +## Render markers in consumer files + +The generator never edits files outside a marker comment. Mark each generated +region with one comment of the host syntax, immediately before the JSX element +whose body should be replaced: + +- TS/JS expression context: `// SDK_SNIPPET:RENDER: hash= version=` +- JSX children context: `{/* SDK_SNIPPET:RENDER: hash= version= */}` +- Block comment anywhere: `/* SDK_SNIPPET:RENDER: hash= version= */` + +`hash` and `version` are filled in by `snippets render`. On first wiring, use +`hash=000000000000` as a placeholder — the next render rewrites it. + +## CLI quick reference + +```sh +# Validate a snippet end-to-end in Docker, against a real LD environment +export LAUNCHDARKLY_SDK_KEY=... # server-side key +export LAUNCHDARKLY_FLAG_KEY=... # flag the snippet evaluates +snippets validate --sdk=python-server-sdk + +# Rewrite all marked regions in an ld-application checkout +snippets render --target=ld-application --out=/path/to/ld-application + +# Confirm consumer file matches what we'd render (no edits) +snippets verify --target=ld-application --out=/path/to/ld-application +``` + +## Validator inputs + +Validation runs against a real LaunchDarkly environment. The caller supplies: + +| Env var | Mapped to inputs of `type` | +|---|---| +| `LAUNCHDARKLY_SDK_KEY` | passed to the snippet via the same env var inside the Docker container; the snippet reads it the same way the `hello-*` sample apps do | +| `LAUNCHDARKLY_FLAG_KEY` | substituted into any input of `type: flag-key` at render-for-validation time | + +Snippet frontmatter does **not** carry default values for `flag-key` / `sdk-key` / +`mobile-key` / `client-side-id` types — those always come from the caller's +environment so real keys never end up committed to this repo. diff --git a/snippets/go.mod b/snippets/go.mod new file mode 100644 index 0000000..3c053c6 --- /dev/null +++ b/snippets/go.mod @@ -0,0 +1,5 @@ +module github.com/launchdarkly/sdk-meta/snippets + +go 1.24.3 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/snippets/go.sum b/snippets/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/snippets/go.sum @@ -0,0 +1,4 @@ +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/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/snippets/internal/adapters/ldapplication/descriptor.go b/snippets/internal/adapters/ldapplication/descriptor.go new file mode 100644 index 0000000..6ad001c --- /dev/null +++ b/snippets/internal/adapters/ldapplication/descriptor.go @@ -0,0 +1,28 @@ +package ldapplication + +import ( + "os" + + "gopkg.in/yaml.v3" +) + +// descriptor is the subset of sdk.yaml that the ld-application adapter +// cares about. +type descriptor struct { + ID string `yaml:"id"` + LDApplication struct { + GetStartedFile string `yaml:"get-started-file"` + } `yaml:"ld-application"` +} + +func loadDescriptor(path string) (*descriptor, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var d descriptor + if err := yaml.Unmarshal(raw, &d); err != nil { + return nil, err + } + return &d, nil +} diff --git a/snippets/internal/adapters/ldapplication/ldapplication.go b/snippets/internal/adapters/ldapplication/ldapplication.go new file mode 100644 index 0000000..ecb5e97 --- /dev/null +++ b/snippets/internal/adapters/ldapplication/ldapplication.go @@ -0,0 +1,209 @@ +package ldapplication + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/launchdarkly/sdk-meta/snippets/internal/markers" + "github.com/launchdarkly/sdk-meta/snippets/internal/model" + "github.com/launchdarkly/sdk-meta/snippets/internal/render" + "github.com/launchdarkly/sdk-meta/snippets/internal/version" +) + +// Render walks every SDK's get-started TSX file under appDir, finds render +// markers, and rewrites each marked region with the rendered snippet content. +// Returns one entry per file it touched. +func Render(sdksDir, appDir string) ([]string, error) { + snippets, err := model.LoadAll(sdksDir) + if err != nil { + return nil, err + } + + files, err := discoverTargetFiles(sdksDir, appDir) + if err != nil { + return nil, err + } + + var changed []string + for _, path := range files { + ok, err := rewriteFile(path, snippets, false) + if err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + if ok { + changed = append(changed, path) + } + } + return changed, nil +} + +// Verify re-renders every marked region in memory and fails if any hash in a +// marker does not match the hash of the bytes currently in the file, or if a +// re-render would change content. Never modifies files. +func Verify(sdksDir, appDir string) error { + snippets, err := model.LoadAll(sdksDir) + if err != nil { + return err + } + + files, err := discoverTargetFiles(sdksDir, appDir) + if err != nil { + return err + } + + for _, path := range files { + if _, err := rewriteFile(path, snippets, true); err != nil { + return fmt.Errorf("%s: %w", path, err) + } + } + return nil +} + +// discoverTargetFiles returns every file referenced by an sdk.yaml's +// `ld-application.get-started-file` field, resolved relative to appDir. +func discoverTargetFiles(sdksDir, appDir string) ([]string, error) { + entries, err := os.ReadDir(sdksDir) + if err != nil { + return nil, err + } + var out []string + for _, e := range entries { + if !e.IsDir() { + continue + } + descPath := filepath.Join(sdksDir, e.Name(), "sdk.yaml") + desc, err := loadDescriptor(descPath) + if err != nil { + if os.IsNotExist(err) { + continue + } + return nil, err + } + if desc.LDApplication.GetStartedFile == "" { + continue + } + full := filepath.Join(appDir, desc.LDApplication.GetStartedFile) + if _, err := os.Stat(full); err != nil { + return nil, fmt.Errorf("descriptor %s: target not found: %w", descPath, err) + } + out = append(out, full) + } + return out, nil +} + +// rewriteFile does the actual in-place rewrite. If dryRun is true it only +// verifies that (a) every marker hash matches the current file content and +// (b) re-rendering produces the same bytes. +func rewriteFile(path string, snippets map[string]*model.Snippet, dryRun bool) (bool, error) { + raw, err := os.ReadFile(path) + if err != nil { + return false, err + } + src := string(raw) + + matches, err := markers.ScanTSX(src) + if err != nil { + return false, err + } + if len(matches) == 0 { + return false, nil + } + + var sb strings.Builder + cursor := 0 + changed := false + for _, m := range matches { + s := snippets[m.Fields.ID] + if s == nil { + return false, fmt.Errorf("marker %q references unknown snippet id", m.Fields.ID) + } + tpl, err := render.Parse(s.CodeBody) + if err != nil { + return false, fmt.Errorf("snippet %s: %w", s.Path, err) + } + rendered := render.RenderForLDApplication(tpl) + jsxBody := wrapForJSX(rendered, src, m.RegionStart, m.RegionEnd) + + if dryRun { + actualHash := markers.HashContent(src[m.RegionStart:m.RegionEnd]) + if m.Fields.Hash != "" && m.Fields.Hash != actualHash { + return false, fmt.Errorf("marker %q: hand-edit detected — file hash %s does not match marker %s", + m.Fields.ID, actualHash, m.Fields.Hash) + } + if jsxBody != src[m.RegionStart:m.RegionEnd] { + return false, fmt.Errorf("marker %q: re-render would change region — run `snippets render`", m.Fields.ID) + } + continue + } + + newMarker := markers.FormatMarker(m.Style, markers.MarkerFields{ + ID: m.Fields.ID, + Hash: markers.HashContent(jsxBody), + Version: version.Version, + Scope: "content", + }) + sb.WriteString(src[cursor:m.CommentStart]) + sb.WriteString(newMarker) + sb.WriteString(src[m.CommentEnd:m.RegionStart]) + sb.WriteString(jsxBody) + cursor = m.RegionEnd + + if src[m.CommentStart:m.CommentEnd] != newMarker || src[m.RegionStart:m.RegionEnd] != jsxBody { + changed = true + } + } + if dryRun { + return false, nil + } + sb.WriteString(src[cursor:]) + if !changed { + return false, nil + } + return true, os.WriteFile(path, []byte(sb.String()), 0o644) +} + +// wrapForJSX produces the bytes that belong between and . +// To keep cut-over diffs minimal, it preserves the surrounding-whitespace +// shape of the existing region: +// - leading whitespace (between `>` and the first non-space char) is reused +// - trailing whitespace (between the last non-space char and ` leadEnd && isSpace(s[trailStart-1]) { + trailStart-- + } + return s[:leadEnd], s[trailStart:] +} + +func isSpace(b byte) bool { return b == ' ' || b == '\t' || b == '\n' || b == '\r' } diff --git a/snippets/internal/markers/markers.go b/snippets/internal/markers/markers.go new file mode 100644 index 0000000..dc6269b --- /dev/null +++ b/snippets/internal/markers/markers.go @@ -0,0 +1,280 @@ +package markers + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "regexp" + "strings" +) + +// HashContent returns the first 12 hex chars of the SHA-256 of the bytes. +// This is the hash written into render markers. +func HashContent(content string) string { + sum := sha256.Sum256([]byte(content)) + return hex.EncodeToString(sum[:])[:12] +} + +// MarkerFields is the set of key=value pairs we recognize after the ID. +type MarkerFields struct { + ID string + Hash string + Version string + Scope string // "content" (default), "element", or "file" +} + +// commentStyle records how the marker was spelled so a rewrite preserves it. +type commentStyle int + +const ( + styleLine commentStyle = iota // `// SDK_SNIPPET:...\n` + styleJSXExpr // `{/* SDK_SNIPPET:... */}` + styleBlock // `/* SDK_SNIPPET:... */` +) + +// Match describes one marker occurrence in a TSX/TS file. +type Match struct { + Fields MarkerFields + Style commentStyle + + // Byte offsets: [CommentStart, CommentEnd) is the marker comment itself. + CommentStart, CommentEnd int + + // The JSX element that follows. For scope=content: + // [RegionStart, RegionEnd) is the element's children (between > and \S+)` + + `(?:\s+hash=(?P[0-9a-fA-F]+))?` + + `(?:\s+version=(?P\S+))?` + + `(?:\s+scope=(?Pcontent|element|file))?`, +) + +// tagNameRe matches the beginning of a JSX component tag: `` that closes ``. Track + // strings and brace depth so `>` inside attributes (e.g., `onClick={x > 0}`) + // doesn't fool us. + depth := 0 + j := commentEnd + offs[1] // right after the tag name + for j < len(src) { + c := src[j] + if depth == 0 { + if c == '/' && j+1 < len(src) && src[j+1] == '>' { + return Match{}, fmt.Errorf("marker %q: element <%s/> is self-closing — scope=content requires a body", m.ID, tag) + } + if c == '>' { + break + } + } + switch c { + case '"', '\'', '`': + quote := c + j++ + for j < len(src) { + if src[j] == '\\' { + j += 2 + continue + } + if src[j] == quote { + break + } + j++ + } + case '{': + depth++ + case '}': + depth-- + } + j++ + } + if j >= len(src) { + return Match{}, fmt.Errorf("marker %q: unterminated <%s opening tag", m.ID, tag) + } + regionStart := j + 1 // byte after `>` + + // Find matching . First-slice does not support same-tag nesting. + closeTag := "" + closeIdx := strings.Index(src[regionStart:], closeTag) + if closeIdx < 0 { + return Match{}, fmt.Errorf("marker %q: no closing tag found", m.ID, tag) + } + regionEnd := regionStart + closeIdx + closeTagEnd := regionEnd + len(closeTag) + + return Match{ + Fields: m, + Style: style, + CommentStart: commentStart, + CommentEnd: commentEnd, + OpenTagStart: openTagStart, + RegionStart: regionStart, + RegionEnd: regionEnd, + CloseTagEnd: closeTagEnd, + TagName: tag, + }, nil +} + +// FormatMarker renders the marker comment in the given style, with hash+version. +func FormatMarker(style commentStyle, f MarkerFields) string { + body := fmt.Sprintf("SDK_SNIPPET:RENDER:%s hash=%s version=%s", f.ID, f.Hash, f.Version) + if f.Scope != "" && f.Scope != "content" { + body += " scope=" + f.Scope + } + switch style { + case styleLine: + return "// " + body + case styleJSXExpr: + return "{/* " + body + " */}" + case styleBlock: + return "/* " + body + " */" + } + return "// " + body +} diff --git a/snippets/internal/markers/markers_test.go b/snippets/internal/markers/markers_test.go new file mode 100644 index 0000000..88e271b --- /dev/null +++ b/snippets/internal/markers/markers_test.go @@ -0,0 +1,56 @@ +package markers + +import "testing" + +func TestScanTSX_LineCommentAndJSXComment(t *testing.T) { + src := `import { Snippet } from 'x'; + +const A = () => ( + // SDK_SNIPPET:RENDER:foo/bar hash=abc version=0.1.0 + {` + "`" + `hello ${x}` + "`" + `} +); + +const B = () => ( +
  • + {/* SDK_SNIPPET:RENDER:foo/baz hash=def version=0.1.0 */} + mkdir hello +
  • +); +` + matches, err := ScanTSX(src) + if err != nil { + t.Fatal(err) + } + if len(matches) != 2 { + t.Fatalf("expected 2 matches, got %d", len(matches)) + } + if matches[0].Fields.ID != "foo/bar" || matches[0].TagName != "Snippet" { + t.Fatalf("match 0: %+v", matches[0]) + } + if matches[0].Style != styleLine { + t.Fatalf("match 0 style: want styleLine, got %v", matches[0].Style) + } + body0 := src[matches[0].RegionStart:matches[0].RegionEnd] + if body0 != "{`hello ${x}`}" { + t.Fatalf("body 0: %q", body0) + } + if matches[1].Fields.ID != "foo/baz" || matches[1].Style != styleJSXExpr { + t.Fatalf("match 1: %+v", matches[1]) + } + body1 := src[matches[1].RegionStart:matches[1].RegionEnd] + if body1 != "mkdir hello" { + t.Fatalf("body 1: %q", body1) + } +} + +func TestScanTSX_IgnoresMarkerInsideString(t *testing.T) { + src := `const s = "// SDK_SNIPPET:RENDER:nope hash=000 version=0.1.0"; +` + matches, err := ScanTSX(src) + if err != nil { + t.Fatal(err) + } + if len(matches) != 0 { + t.Fatalf("expected 0 matches, got %d", len(matches)) + } +} diff --git a/snippets/internal/model/model.go b/snippets/internal/model/model.go new file mode 100644 index 0000000..4c30bf3 --- /dev/null +++ b/snippets/internal/model/model.go @@ -0,0 +1,152 @@ +package model + +import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +// Frontmatter is the YAML block at the top of a .snippet.md file. +// Only the fields this first-pass slice actually uses are modelled; unknown +// fields are ignored so later phases can extend the schema without churn here. +type Frontmatter struct { + ID string `yaml:"id"` + SDK string `yaml:"sdk"` + Kind string `yaml:"kind"` + Lang string `yaml:"lang"` + File string `yaml:"file"` + Description string `yaml:"description"` + Inputs map[string]Input `yaml:"inputs"` + LDApplication LDApplicationHints `yaml:"ld-application"` + Validation Validation `yaml:"validation"` +} + +type Input struct { + Type string `yaml:"type"` + Description string `yaml:"description"` + RuntimeDefault string `yaml:"runtime-default"` +} + +type LDApplicationHints struct { + Slot string `yaml:"slot"` +} + +type Validation struct { + Entrypoint string `yaml:"entrypoint"` + Requirements string `yaml:"requirements"` +} + +// Snippet pairs the frontmatter with the body of the first fenced code block +// in the markdown. The first-pass snippet file format is exactly "one fenced +// code block per file"; later phases may extend to multiple blocks per file. +type Snippet struct { + Path string + Frontmatter Frontmatter + CodeLang string + CodeBody string +} + +var frontmatterRe = regexp.MustCompile(`(?s)\A---\n(.*?)\n---\n`) +var fenceOpenRe = regexp.MustCompile("(?m)^```([a-zA-Z0-9_+-]*)\\s*$") +var fenceCloseRe = regexp.MustCompile("(?m)^```\\s*$") + +// ParseFile reads a .snippet.md file and returns the parsed Snippet. +func ParseFile(path string) (*Snippet, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + m := frontmatterRe.FindSubmatchIndex(raw) + if m == nil { + return nil, fmt.Errorf("%s: missing YAML frontmatter", path) + } + var fm Frontmatter + if err := yaml.Unmarshal(raw[m[2]:m[3]], &fm); err != nil { + return nil, fmt.Errorf("%s: frontmatter parse: %w", path, err) + } + body := raw[m[1]:] + + lang, code, err := firstCodeBlock(body) + if err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + return &Snippet{ + Path: path, + Frontmatter: fm, + CodeLang: lang, + CodeBody: code, + }, nil +} + +// firstCodeBlock returns the language and body of the first fenced code block. +// The body does NOT include the trailing newline before the closing fence. +func firstCodeBlock(body []byte) (string, string, error) { + openIdx := fenceOpenRe.FindSubmatchIndex(body) + if openIdx == nil { + return "", "", errors.New("no fenced code block found") + } + lang := string(body[openIdx[2]:openIdx[3]]) + // Content starts on the line after the opening fence. + after := body[openIdx[1]:] + if !bytes.HasPrefix(after, []byte("\n")) { + return "", "", errors.New("malformed code fence: expected newline after opening fence") + } + after = after[1:] + closeIdx := fenceCloseRe.FindIndex(after) + if closeIdx == nil { + return "", "", errors.New("unterminated fenced code block") + } + codeBytes := after[:closeIdx[0]] + // Strip the final newline that precedes ```. + codeBytes = bytes.TrimSuffix(codeBytes, []byte("\n")) + return lang, string(codeBytes), nil +} + +// LoadAll walks sdksDir for every *.snippet.md file and returns them indexed by id. +func LoadAll(sdksDir string) (map[string]*Snippet, error) { + out := map[string]*Snippet{} + err := filepath.WalkDir(sdksDir, func(p string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + if !strings.HasSuffix(p, ".snippet.md") { + return nil + } + s, err := ParseFile(p) + if err != nil { + return err + } + if s.Frontmatter.ID == "" { + return fmt.Errorf("%s: frontmatter.id is required", p) + } + if prev, ok := out[s.Frontmatter.ID]; ok { + return fmt.Errorf("duplicate snippet id %q in %s and %s", s.Frontmatter.ID, prev.Path, p) + } + out[s.Frontmatter.ID] = s + return nil + }) + if err != nil { + return nil, err + } + return out, nil +} + +// SortedIDs returns the ids in lexicographic order; useful for deterministic output. +func SortedIDs(snippets map[string]*Snippet) []string { + ids := make([]string, 0, len(snippets)) + for id := range snippets { + ids = append(ids, id) + } + sort.Strings(ids) + return ids +} diff --git a/snippets/internal/render/render.go b/snippets/internal/render/render.go new file mode 100644 index 0000000..05a59dd --- /dev/null +++ b/snippets/internal/render/render.go @@ -0,0 +1,74 @@ +package render + +import ( + "fmt" + "strings" +) + +// RenderRuntime substitutes inputs as concrete values. Used by the validator. +// A missing input is an error — validation must always have a value. +func RenderRuntime(nodes []Node, inputs map[string]string) (string, error) { + var sb strings.Builder + for _, n := range nodes { + switch x := n.(type) { + case *Literal: + sb.WriteString(x.Text) + case *Var: + v, ok := inputs[x.Name] + if !ok { + return "", fmt.Errorf("render: missing runtime input %q", x.Name) + } + sb.WriteString(v) + case *Cond: + v, ok := inputs[x.Var] + if !ok { + return "", fmt.Errorf("render: missing runtime input %q (used in conditional)", x.Var) + } + if v != "" { + inner, err := RenderRuntime(x.Body, inputs) + if err != nil { + return "", err + } + sb.WriteString(inner) + } + } + } + return sb.String(), nil +} + +// RenderForLDApplication produces the body that sits between the surrounding +// TSX backticks for the ld-application adapter. Substitutions become `${name}` +// expressions; conditionals become `${name ? `inner` : ''}` ternaries (nesting +// template literals, which is legal JS since the inner backticks are inside an +// ${} expression). +// +// The caller wraps the result in `...` when embedding in JSX. +func RenderForLDApplication(nodes []Node) string { + var sb strings.Builder + for _, n := range nodes { + switch x := n.(type) { + case *Literal: + sb.WriteString(escapeTL(x.Text)) + case *Var: + sb.WriteString("${") + sb.WriteString(x.Name) + sb.WriteString("}") + case *Cond: + sb.WriteString("${") + sb.WriteString(x.Var) + sb.WriteString(" ? `") + sb.WriteString(RenderForLDApplication(x.Body)) + sb.WriteString("` : ''}") + } + } + return sb.String() +} + +// escapeTL escapes literal content for inclusion inside a JS template literal. +// Order matters: escape backslashes first, then backticks and ${ sequences. +func escapeTL(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, "`", "\\`") + s = strings.ReplaceAll(s, "${", "\\${") + return s +} diff --git a/snippets/internal/render/template.go b/snippets/internal/render/template.go new file mode 100644 index 0000000..2e5637e --- /dev/null +++ b/snippets/internal/render/template.go @@ -0,0 +1,85 @@ +package render + +import ( + "fmt" + "regexp" + "strings" +) + +// The snippet templating language is intentionally tiny: +// +// {{ varName }} substitute the value of an input +// {{ if varName }}...{{ end }} emit "..." only if the input is truthy (non-empty) +// +// Conditionals do not nest in the first-pass slice. The inner "..." may still +// contain {{ varName }} substitutions. Future phases can extend this (filters, +// region toggles, version toggles) without breaking existing snippets. + +type Node interface{ isNode() } + +type Literal struct{ Text string } +type Var struct{ Name string } +type Cond struct { + Var string + Body []Node +} + +func (*Literal) isNode() {} +func (*Var) isNode() {} +func (*Cond) isNode() {} + +var nameRe = `[a-zA-Z][a-zA-Z0-9_]*` +var tokenRe = regexp.MustCompile(`\{\{\s*(if\s+(` + nameRe + `)\s*|end\s*|(` + nameRe + `)\s*)\}\}`) + +// Parse parses the mini-templating syntax into a flat node list. +// Conditionals are flattened: a Cond node contains its inner body. +func Parse(src string) ([]Node, error) { + matches := tokenRe.FindAllStringSubmatchIndex(src, -1) + + var ( + i = 0 + out []Node + stack []*Cond + append_ = func(n Node) { + if len(stack) > 0 { + top := stack[len(stack)-1] + top.Body = append(top.Body, n) + } else { + out = append(out, n) + } + } + ) + for _, m := range matches { + start, end := m[0], m[1] + if start > i { + append_(&Literal{Text: src[i:start]}) + } + token := src[start:end] + switch { + case m[4] >= 0: // "if NAME" + name := src[m[4]:m[5]] + c := &Cond{Var: name} + stack = append(stack, c) + case strings.HasPrefix(strings.TrimSpace(token[2:len(token)-2]), "end"): + if len(stack) == 0 { + return nil, fmt.Errorf("template: unmatched {{ end }} at offset %d", start) + } + closed := stack[len(stack)-1] + stack = stack[:len(stack)-1] + append_(closed) + case m[6] >= 0: // "NAME" + name := src[m[6]:m[7]] + append_(&Var{Name: name}) + default: + return nil, fmt.Errorf("template: unrecognized directive %q at offset %d", token, start) + } + i = end + } + if i < len(src) { + append_(&Literal{Text: src[i:]}) + } + if len(stack) > 0 { + return nil, fmt.Errorf("template: unclosed {{ if %s }}", stack[len(stack)-1].Var) + } + return out, nil +} diff --git a/snippets/internal/render/template_test.go b/snippets/internal/render/template_test.go new file mode 100644 index 0000000..b877fc9 --- /dev/null +++ b/snippets/internal/render/template_test.go @@ -0,0 +1,46 @@ +package render + +import "testing" + +func TestParseAndRender(t *testing.T) { + nodes, err := Parse(`echo "launchdarkly-server-sdk{{ if version }}=={{ version }}{{ end }}" done`) + if err != nil { + t.Fatal(err) + } + + // Runtime, version set + got, err := RenderRuntime(nodes, map[string]string{"version": "9.2.0"}) + if err != nil { + t.Fatal(err) + } + want := `echo "launchdarkly-server-sdk==9.2.0" done` + if got != want { + t.Fatalf("runtime: got %q want %q", got, want) + } + + // Runtime, version empty → conditional omitted + got, err = RenderRuntime(nodes, map[string]string{"version": ""}) + if err != nil { + t.Fatal(err) + } + want = `echo "launchdarkly-server-sdk" done` + if got != want { + t.Fatalf("runtime empty: got %q want %q", got, want) + } + + // ld-application rendering produces a JS ternary expression + got = RenderForLDApplication(nodes) + want = "echo \"launchdarkly-server-sdk${version ? `==${version}` : ''}\" done" + if got != want { + t.Fatalf("ld-application: got %q want %q", got, want) + } +} + +func TestRenderForLDApplicationEscapes(t *testing.T) { + nodes, _ := Parse("a \\ b ` c ${d} {{ name }}") + got := RenderForLDApplication(nodes) + want := "a \\\\ b \\` c \\${d} ${name}" + if got != want { + t.Fatalf("escapes: got %q want %q", got, want) + } +} diff --git a/snippets/internal/validate/validate.go b/snippets/internal/validate/validate.go new file mode 100644 index 0000000..d320d65 --- /dev/null +++ b/snippets/internal/validate/validate.go @@ -0,0 +1,148 @@ +package validate + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/launchdarkly/sdk-meta/snippets/internal/model" + "github.com/launchdarkly/sdk-meta/snippets/internal/render" +) + +// Config controls a validator run. +type Config struct { + SDKsDir string // path to sdks/ + ValidatorsDir string // path to validators/ + SDK string // sdk id to validate (empty = all) +} + +// Run finds validatable snippets under cfg.SDKsDir and runs each through +// the per-language Docker validator. First-pass implementation: python only. +// +// Snippets are run against a real LaunchDarkly environment. Required env vars, +// matching the convention used by the hello-* sample apps: +// +// LAUNCHDARKLY_SDK_KEY server-side SDK key for the test environment +// LAUNCHDARKLY_FLAG_KEY the flag key the snippet should evaluate +// +// These are read from the caller's environment and forwarded into the +// per-snippet Docker run. They are never written to a file in the repo. +func Run(cfg Config) error { + sdkKey := os.Getenv("LAUNCHDARKLY_SDK_KEY") + flagKey := os.Getenv("LAUNCHDARKLY_FLAG_KEY") + if sdkKey == "" || flagKey == "" { + return fmt.Errorf("LAUNCHDARKLY_SDK_KEY and LAUNCHDARKLY_FLAG_KEY must be set in the caller environment") + } + + snippets, err := model.LoadAll(cfg.SDKsDir) + if err != nil { + return err + } + + any := false + for _, id := range model.SortedIDs(snippets) { + s := snippets[id] + if cfg.SDK != "" && s.Frontmatter.SDK != cfg.SDK { + continue + } + if s.Frontmatter.Validation.Entrypoint == "" { + continue + } + any = true + if err := runOne(cfg, s, sdkKey, flagKey); err != nil { + return fmt.Errorf("validate %s: %w", id, err) + } + } + if !any { + return fmt.Errorf("no validatable snippets found (sdk=%q)", cfg.SDK) + } + return nil +} + +func runOne(cfg Config, s *model.Snippet, sdkKey, flagKey string) error { + switch s.CodeLang { + case "python": + return runPython(cfg, s, sdkKey, flagKey) + default: + return fmt.Errorf("no validator for lang=%q", s.CodeLang) + } +} + +func runPython(cfg Config, s *model.Snippet, sdkKey, flagKey string) error { + inputs := runtimeInputs(s, flagKey) + nodes, err := render.Parse(s.CodeBody) + if err != nil { + return err + } + code, err := render.RenderRuntime(nodes, inputs) + if err != nil { + return err + } + + stageDir, err := os.MkdirTemp("", "snippets-validate-") + if err != nil { + return err + } + defer os.RemoveAll(stageDir) + + entrypoint := s.Frontmatter.Validation.Entrypoint + if err := os.WriteFile(filepath.Join(stageDir, entrypoint), []byte(code), 0o644); err != nil { + return err + } + if s.Frontmatter.Validation.Requirements != "" { + if err := os.WriteFile(filepath.Join(stageDir, "requirements.txt"), + []byte(s.Frontmatter.Validation.Requirements+"\n"), 0o644); err != nil { + return err + } + } + + validatorDir := filepath.Join(cfg.ValidatorsDir, "languages", "python") + if _, err := os.Stat(filepath.Join(validatorDir, "Dockerfile")); err != nil { + return fmt.Errorf("validator Dockerfile not found at %s: %w", validatorDir, err) + } + + imageTag := "sdk-snippets/python-validator:dev" + build := exec.Command("docker", "build", "--quiet", "-t", imageTag, validatorDir) + build.Stdout = os.Stdout + build.Stderr = os.Stderr + if err := build.Run(); err != nil { + return fmt.Errorf("docker build failed: %w", err) + } + + fmt.Printf("--- validate %s (lang=%s, entrypoint=%s) ---\n", s.Frontmatter.ID, s.CodeLang, entrypoint) + + run := exec.Command("docker", "run", "--rm", + "-v", stageDir+":/snippet:ro", + "-e", "SNIPPET_ENTRYPOINT="+entrypoint, + "-e", "LAUNCHDARKLY_SDK_KEY="+sdkKey, + "-e", "LAUNCHDARKLY_FLAG_KEY="+flagKey, + imageTag, + ) + run.Stdout = os.Stdout + run.Stderr = os.Stderr + if err := run.Run(); err != nil { + return fmt.Errorf("snippet runtime validation failed: %w", err) + } + return nil +} + +// runtimeInputs derives concrete values for every declared input. +// Inputs typed as `flag-key` use the LAUNCHDARKLY_FLAG_KEY env value. Inputs +// typed as `sdk-key` use the LAUNCHDARKLY_SDK_KEY env value (the snippet's +// rendered source needs the literal key when it's interpolated, e.g. in the +// `Run` shell command — but the Python `main.py` reads it from the env and +// never has the key in its source). Other inputs fall back to the snippet's +// own runtime-default. +func runtimeInputs(s *model.Snippet, flagKey string) map[string]string { + out := map[string]string{} + for name, in := range s.Frontmatter.Inputs { + switch in.Type { + case "flag-key": + out[name] = flagKey + default: + out[name] = in.RuntimeDefault + } + } + return out +} diff --git a/snippets/internal/version/version.go b/snippets/internal/version/version.go new file mode 100644 index 0000000..49f4531 --- /dev/null +++ b/snippets/internal/version/version.go @@ -0,0 +1,5 @@ +package version + +// Version is the sdk-snippets release this binary was built from. +// Release-please will update this at release time; for development it stays at 0.1.0. +const Version = "0.1.0" diff --git a/snippets/sdks/python-server-sdk/sdk.yaml b/snippets/sdks/python-server-sdk/sdk.yaml new file mode 100644 index 0000000..f5dc382 --- /dev/null +++ b/snippets/sdks/python-server-sdk/sdk.yaml @@ -0,0 +1,14 @@ +id: python-server-sdk +sdk-meta-id: python +display-name: Python +type: server-side +languages: + - id: python + extensions: [".py"] +package-managers: [pip] +regions: [commercial, federal, eu, relay-proxy] +ld-application: + get-started-file: static/ld/components/getStarted/sdk/python.tsx +docs: + reference-page: /sdk/server-side/python +hello-world-repo: launchdarkly/hello-python diff --git a/snippets/sdks/python-server-sdk/snippets/getting-started/install.snippet.md b/snippets/sdks/python-server-sdk/snippets/getting-started/install.snippet.md new file mode 100644 index 0000000..3fe4367 --- /dev/null +++ b/snippets/sdks/python-server-sdk/snippets/getting-started/install.snippet.md @@ -0,0 +1,20 @@ +--- +id: python-server-sdk/getting-started/install +sdk: python-server-sdk +kind: install +lang: shell +description: Write the SDK dependency into requirements.txt and install it with pip. +inputs: + version: + type: string + description: Optional pinned SDK version; when empty the pin is omitted. + runtime-default: "" +ld-application: + slot: install +--- + +Create a file called `requirements.txt` with the SDK dependency and install it: + +```shell +echo "launchdarkly-server-sdk{{ if version }}=={{ version }}{{ end }}" >> requirements.txt && pip3 install -r requirements.txt +``` diff --git a/snippets/sdks/python-server-sdk/snippets/getting-started/main-py.snippet.md b/snippets/sdks/python-server-sdk/snippets/getting-started/main-py.snippet.md new file mode 100644 index 0000000..f28d5d6 --- /dev/null +++ b/snippets/sdks/python-server-sdk/snippets/getting-started/main-py.snippet.md @@ -0,0 +1,101 @@ +--- +id: python-server-sdk/getting-started/main-py +sdk: python-server-sdk +kind: hello-world +lang: python +file: main.py +description: Hello-world program that initializes the Python server SDK and watches a feature flag. +inputs: + featureKey: + type: flag-key + description: The feature flag key to evaluate. Validation reads this from LAUNCHDARKLY_FLAG_KEY in the caller env. +ld-application: + slot: main-py +validation: + entrypoint: main.py + requirements: launchdarkly-server-sdk +--- + +Create a file called `main.py` and add the following code: + +```python +import os +import ldclient +from ldclient import Context +from ldclient.config import Config +from threading import Lock, Event + + +# Set sdk_key to your LaunchDarkly SDK key. +sdk_key = os.getenv("LAUNCHDARKLY_SDK_KEY") + +# Set feature_flag_key to the feature flag key you want to evaluate. +feature_flag_key = "{{ featureKey }}" + + +def show_evaluation_result(key: str, value: bool): + print() + print(f"*** The {key} feature flag evaluates to {value}") + + +def show_banner(): + print() + print(" ██ ") + print(" ██ ") + print(" ████████ ") + print(" ███████ ") + print("██ LAUNCHDARKLY █") + print(" ███████ ") + print(" ████████ ") + print(" ██ ") + print(" ██ ") + print() + + +class FlagValueChangeListener: + def __init__(self): + self.__show_banner = True + self.__lock = Lock() + + def flag_value_change_listener(self, flag_change): + with self.__lock: + if self.__show_banner and flag_change.new_value: + show_banner() + self.__show_banner = False + + show_evaluation_result(flag_change.key, flag_change.new_value) + + +if __name__ == "__main__": + if not sdk_key: + print("*** Please set the LAUNCHDARKLY_SDK_KEY env first") + exit() + if not feature_flag_key: + print("*** Please set the LAUNCHDARKLY_FLAG_KEY env first") + exit() + + ldclient.set_config(Config(sdk_key)) + + if not ldclient.get().is_initialized(): + print("*** SDK failed to initialize. Please check your internet connection and SDK credential for any typo.") + exit() + + print("*** SDK successfully initialized") + + # Set up the evaluation context. This context should appear on your + # LaunchDarkly contexts dashboard soon after you run the demo. + context = \ + Context.builder('example-user-key').kind('user').name('Sandy').build() + + flag_value = ldclient.get().variation(feature_flag_key, context, False) + show_evaluation_result(feature_flag_key, flag_value) + + change_listener = FlagValueChangeListener() + listener = ldclient.get().flag_tracker \ + .add_flag_value_change_listener(feature_flag_key, context, change_listener.flag_value_change_listener) + + try: + Event().wait() + except KeyboardInterrupt: + pass +``` diff --git a/snippets/sdks/python-server-sdk/snippets/getting-started/mkdir.snippet.md b/snippets/sdks/python-server-sdk/snippets/getting-started/mkdir.snippet.md new file mode 100644 index 0000000..51b3786 --- /dev/null +++ b/snippets/sdks/python-server-sdk/snippets/getting-started/mkdir.snippet.md @@ -0,0 +1,15 @@ +--- +id: python-server-sdk/getting-started/mkdir +sdk: python-server-sdk +kind: bootstrap +lang: shell +description: Create the project directory for the Python hello-world. +ld-application: + slot: mkdir +--- + +Create a new directory for the project: + +```shell +mkdir hello-python && cd hello-python +``` diff --git a/snippets/sdks/python-server-sdk/snippets/getting-started/run.snippet.md b/snippets/sdks/python-server-sdk/snippets/getting-started/run.snippet.md new file mode 100644 index 0000000..7f4b799 --- /dev/null +++ b/snippets/sdks/python-server-sdk/snippets/getting-started/run.snippet.md @@ -0,0 +1,19 @@ +--- +id: python-server-sdk/getting-started/run +sdk: python-server-sdk +kind: run +lang: shell +description: Run the hello-world program with the LaunchDarkly SDK key in the environment. +inputs: + apiKey: + type: sdk-key + description: The SDK key to embed in the rendered Run command. +ld-application: + slot: run +--- + +Run the program: + +```shell +LAUNCHDARKLY_SDK_KEY="{{ apiKey }}" python main.py +``` diff --git a/snippets/validators/languages/python/Dockerfile b/snippets/validators/languages/python/Dockerfile new file mode 100644 index 0000000..d5fb464 --- /dev/null +++ b/snippets/validators/languages/python/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.11-slim + +WORKDIR /snippet + +COPY harness /harness + +ENTRYPOINT ["/harness/run.sh"] diff --git a/snippets/validators/languages/python/harness/run.sh b/snippets/validators/languages/python/harness/run.sh new file mode 100755 index 0000000..51f335b --- /dev/null +++ b/snippets/validators/languages/python/harness/run.sh @@ -0,0 +1,57 @@ +#!/bin/sh +# Validator entrypoint. Runs the staged snippet against a real LaunchDarkly +# environment. +# +# Required env (passed in by `snippets validate`): +# LAUNCHDARKLY_SDK_KEY server-side SDK key for the test environment +# LAUNCHDARKLY_FLAG_KEY the flag the snippet is templated to evaluate +# SNIPPET_ENTRYPOINT file under /snippet to run (e.g. main.py) +# +# Success criterion: the snippet prints +# `*** The feature flag evaluates to ...` +# within the timeout. That line only appears on a successful SDK init + flag +# evaluation, so matching it is a real signal. +set -eu + +: "${LAUNCHDARKLY_SDK_KEY:?LAUNCHDARKLY_SDK_KEY not set}" +: "${LAUNCHDARKLY_FLAG_KEY:?LAUNCHDARKLY_FLAG_KEY not set}" +: "${SNIPPET_ENTRYPOINT:?SNIPPET_ENTRYPOINT not set}" + +if [ -f /snippet/requirements.txt ]; then + pip install --quiet --no-input -r /snippet/requirements.txt +fi + +LOG=$(mktemp) +trap 'rm -f "$LOG"' EXIT + +cd /snippet + +# Hello-world programs block on Event().wait(); run with a timeout. We watch +# the log for the success line and SIGTERM the process as soon as it appears. +PYTHONUNBUFFERED=1 timeout --signal=TERM 30s python "$SNIPPET_ENTRYPOINT" >"$LOG" 2>&1 & +PID=$! + +deadline=$(( $(date +%s) + 25 )) +prefix="*** The ${LAUNCHDARKLY_FLAG_KEY} feature flag evaluates to " + +while [ "$(date +%s)" -lt "$deadline" ]; do + if grep -F -- "$prefix" "$LOG" >/dev/null 2>&1; then + kill -TERM "$PID" 2>/dev/null || true + wait "$PID" 2>/dev/null || true + # Echo the matched line so the caller sees the actual value. + grep -F -- "$prefix" "$LOG" | head -1 + echo "validator: ok" + exit 0 + fi + if ! kill -0 "$PID" 2>/dev/null; then + break + fi + sleep 0.2 +done + +kill -TERM "$PID" 2>/dev/null || true +wait "$PID" 2>/dev/null || true +echo "validator: did not see expected line: ${prefix}" >&2 +echo "--- snippet output ---" >&2 +cat "$LOG" >&2 +exit 1 From 46ebb1e371d48cfaf15e760692b0b5590a05e0cf Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 24 Apr 2026 18:40:23 -0700 Subject: [PATCH 2/4] fix(snippets): address PR #396 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings 1-19 from the multi-agent review (artifacts/multi-review-sdk-meta-396.md). All worth-fixing items are addressed in this commit; the four "could not prove" hardening items are deferred (they remain open documentation in the review file). Security and correctness ------------------------ - #1, #5: Marker hash now covers the full element, not just the children, so attribute-only edits (e.g. lang="python" → lang="go") are detected by `verify`. The `hash=` field is required at verify time; a missing hash is an error rather than a skip. - #2: validation.entrypoint must be a plain filename (filepath.Base equal, no path separators or ..). Blocks the "snippet writes to ~/.ssh" class of attack via author-controlled YAML. - #3: ld-application.get-started-file is rejected if it's absolute or if filepath.Rel(appDir, full) starts with "..". Blocks the same class via the consumer-side path. - #4: Template tokenizer was treating any token starting with "end" (`endTime`, `endIndex`, …) as `{{ end }}`. Switched HasPrefix → equality. - #6: Marker scanner now tracks
    `) doesn't silently truncate to the inner close. Same-prefix tags (``) don't count as opens of ``. - #7: runtimeInputs has a `sdk-key` arm wired to LAUNCHDARKLY_SDK_KEY, with a defensive check that flag-key/sdk-key inputs cannot declare a runtime-default (those values must always come from the environment). - #8: validation.requirements rejects newlines and lines starting with `-` so a snippet can't smuggle `--extra-index-url` through to pip. - #9: Backtick-string scanner now tracks `${ … }` expression depth, so a nested template literal inside an interpolation expression doesn't prematurely end the outer string. - #10: Render path split into RenderForLDApplicationTemplate (escaping for backtick literals) and RenderForJSXText (no escaping; for bare text). The bare-text path no longer corrupts backslashes / backticks in user-visible output. - #11: Atomic write — temp file in the same directory, fsync, rename; source file mode is preserved. - #12: Validator Docker tag is a content hash of the validator dir, so concurrent runs against the same validator share the cached image and runs against different validators cannot interleave. - #13: run.sh redacts LAUNCHDARKLY_SDK_KEY from the log dump on failure. - #14: Bare-vs-backticks decision is driven by the snippet's intent (interpolation / multiline / JSX-special chars), not by what's already in the file. Re-renders no longer stay sticky on the wrapped form. - #17: snippet frontmatter and sdk.yaml are decoded with KnownFields(true) so a typo like `Entrpoint:` is a hard error. Cosmetic / docs --------------- - #15: AUTHORING.md notes the uppercase-first JSX-component-tag constraint. - #16: hashLen const + comment documenting the 12-hex-char (~48 bit) budget is for accidental-drift detection only, not integrity. - #19: go.mod uses `go 1.24` (drops the patch version). Tests ----- - New tests for: endFoo regression, unmatched/unclosed `{{ if/end }}`, unknown variable, empty template, RenderForJSXText backslash round-trip, HasInterpolation, ContainsJSXSpecial, scanner edge cases (no markers, block-style, unterminated comment, gap, self-closing, missing close, nested same-tag, similar-prefix tag, nested-backtick), full-element hash detects attribute edits, descriptor-traversal rejects, runtimeInputs rejects runtime-default on key types, requirements rejects pip flags, entrypoint rejects path components. Verified end-to-end ------------------- - go build ./... / go vet ./... / go test ./... all clean - snippets render --target=ld-application --out=: rewrites the file with full-element hashes; second run reports "no changes". - snippets verify ok on the freshly rendered file. - snippets verify rejects: a one-byte attribute edit (lang="python" → lang="go") AND a marker with the hash= field stripped. - snippets validate (with real LAUNCHDARKLY_SDK_KEY / LAUNCHDARKLY_FLAG_KEY) matches the expected flag-evaluation line. --- snippets/docs/AUTHORING.md | 7 + snippets/go.mod | 2 +- .../adapters/ldapplication/descriptor.go | 31 ++- .../adapters/ldapplication/ldapplication.go | 160 ++++++++++--- .../ldapplication/ldapplication_test.go | 185 ++++++++++++++ snippets/internal/markers/markers.go | 226 ++++++++++++++---- snippets/internal/markers/markers_test.go | 141 ++++++++++- snippets/internal/model/model.go | 4 +- snippets/internal/render/render.go | 60 ++++- snippets/internal/render/template.go | 6 +- snippets/internal/render/template_test.go | 111 ++++++++- snippets/internal/validate/validate.go | 120 +++++++++- snippets/internal/validate/validate_test.go | 105 ++++++++ .../languages/python/harness/run.sh | 7 +- 14 files changed, 1052 insertions(+), 113 deletions(-) create mode 100644 snippets/internal/adapters/ldapplication/ldapplication_test.go create mode 100644 snippets/internal/validate/validate_test.go diff --git a/snippets/docs/AUTHORING.md b/snippets/docs/AUTHORING.md index f17fb49..8ad59e9 100644 --- a/snippets/docs/AUTHORING.md +++ b/snippets/docs/AUTHORING.md @@ -50,6 +50,13 @@ whose body should be replaced: `hash` and `version` are filled in by `snippets render`. On first wiring, use `hash=000000000000` as a placeholder — the next render rewrites it. +The element directly following a marker MUST be a capitalized JSX component +tag (e.g. ``, ``). Lowercase HTML tags (`
    `, ``)
    +are not recognized; wrap the content in a component first if you need to mark
    +it. The hash committed by the marker covers the *entire* ``
    +region — opening tag, attributes, body, closing tag — so attribute edits like
    +`lang="python"` → `lang="go"` are caught by `verify`.
    +
     ## CLI quick reference
     
     ```sh
    diff --git a/snippets/go.mod b/snippets/go.mod
    index 3c053c6..8c024ec 100644
    --- a/snippets/go.mod
    +++ b/snippets/go.mod
    @@ -1,5 +1,5 @@
     module github.com/launchdarkly/sdk-meta/snippets
     
    -go 1.24.3
    +go 1.24
     
     require gopkg.in/yaml.v3 v3.0.1
    diff --git a/snippets/internal/adapters/ldapplication/descriptor.go b/snippets/internal/adapters/ldapplication/descriptor.go
    index 6ad001c..3a247cb 100644
    --- a/snippets/internal/adapters/ldapplication/descriptor.go
    +++ b/snippets/internal/adapters/ldapplication/descriptor.go
    @@ -1,18 +1,37 @@
     package ldapplication
     
     import (
    +	"bytes"
     	"os"
     
     	"gopkg.in/yaml.v3"
     )
     
    -// descriptor is the subset of sdk.yaml that the ld-application adapter
    -// cares about.
    +// descriptor models the complete sdk.yaml schema (not just the
    +// ld-application subset) so we can decode with KnownFields(true) and
    +// catch typos like `Entrpoint:` or `get-startted-file:` rather than
    +// silently dropping them. Fields we don't read yet are still listed so
    +// the strict decode doesn't reject them.
     type descriptor struct {
    -	ID            string `yaml:"id"`
    -	LDApplication struct {
    +	ID              string         `yaml:"id"`
    +	SDKMetaID       string         `yaml:"sdk-meta-id"`
    +	DisplayName     string         `yaml:"display-name"`
    +	Type            string         `yaml:"type"`
    +	Languages       []descLanguage `yaml:"languages"`
    +	PackageManagers []string       `yaml:"package-managers"`
    +	Regions         []string       `yaml:"regions"`
    +	HelloWorldRepo  string         `yaml:"hello-world-repo"`
    +	LDApplication   struct {
     		GetStartedFile string `yaml:"get-started-file"`
     	} `yaml:"ld-application"`
    +	Docs struct {
    +		ReferencePage string `yaml:"reference-page"`
    +	} `yaml:"docs"`
    +}
    +
    +type descLanguage struct {
    +	ID         string   `yaml:"id"`
    +	Extensions []string `yaml:"extensions"`
     }
     
     func loadDescriptor(path string) (*descriptor, error) {
    @@ -21,7 +40,9 @@ func loadDescriptor(path string) (*descriptor, error) {
     		return nil, err
     	}
     	var d descriptor
    -	if err := yaml.Unmarshal(raw, &d); err != nil {
    +	dec := yaml.NewDecoder(bytes.NewReader(raw))
    +	dec.KnownFields(true)
    +	if err := dec.Decode(&d); err != nil {
     		return nil, err
     	}
     	return &d, nil
    diff --git a/snippets/internal/adapters/ldapplication/ldapplication.go b/snippets/internal/adapters/ldapplication/ldapplication.go
    index ecb5e97..2b41289 100644
    --- a/snippets/internal/adapters/ldapplication/ldapplication.go
    +++ b/snippets/internal/adapters/ldapplication/ldapplication.go
    @@ -1,6 +1,8 @@
     package ldapplication
     
     import (
    +	"crypto/rand"
    +	"encoding/hex"
     	"fmt"
     	"os"
     	"path/filepath"
    @@ -63,11 +65,20 @@ func Verify(sdksDir, appDir string) error {
     
     // discoverTargetFiles returns every file referenced by an sdk.yaml's
     // `ld-application.get-started-file` field, resolved relative to appDir.
    +//
    +// Each referenced path must be a clean relative path that stays inside
    +// appDir. This guards against a malicious sdk.yaml committing
    +// `get-started-file: ../../../foo` and the renderer overwriting arbitrary
    +// files outside the consumer checkout.
     func discoverTargetFiles(sdksDir, appDir string) ([]string, error) {
     	entries, err := os.ReadDir(sdksDir)
     	if err != nil {
     		return nil, err
     	}
    +	absAppDir, err := filepath.Abs(appDir)
    +	if err != nil {
    +		return nil, err
    +	}
     	var out []string
     	for _, e := range entries {
     		if !e.IsDir() {
    @@ -81,10 +92,20 @@ func discoverTargetFiles(sdksDir, appDir string) ([]string, error) {
     			}
     			return nil, err
     		}
    -		if desc.LDApplication.GetStartedFile == "" {
    +		rel := desc.LDApplication.GetStartedFile
    +		if rel == "" {
     			continue
     		}
    -		full := filepath.Join(appDir, desc.LDApplication.GetStartedFile)
    +		if filepath.IsAbs(rel) {
    +			return nil, fmt.Errorf("descriptor %s: get-started-file %q must be relative", descPath, rel)
    +		}
    +		full := filepath.Join(absAppDir, rel)
    +		// Reject any path that escapes appDir. filepath.Rel followed by a
    +		// `..` prefix check is the canonical way to do this.
    +		relCheck, err := filepath.Rel(absAppDir, full)
    +		if err != nil || relCheck == ".." || strings.HasPrefix(relCheck, ".."+string(filepath.Separator)) {
    +			return nil, fmt.Errorf("descriptor %s: get-started-file %q escapes appDir", descPath, rel)
    +		}
     		if _, err := os.Stat(full); err != nil {
     			return nil, fmt.Errorf("descriptor %s: target not found: %w", descPath, err)
     		}
    @@ -94,8 +115,9 @@ func discoverTargetFiles(sdksDir, appDir string) ([]string, error) {
     }
     
     // rewriteFile does the actual in-place rewrite. If dryRun is true it only
    -// verifies that (a) every marker hash matches the current file content and
    -// (b) re-rendering produces the same bytes.
    +// verifies that (a) every marker carries a hash field, (b) that hash matches
    +// the full ... region in the file, and (c) re-rendering would
    +// produce the same bytes.
     func rewriteFile(path string, snippets map[string]*model.Snippet, dryRun bool) (bool, error) {
     	raw, err := os.ReadFile(path)
     	if err != nil {
    @@ -111,6 +133,7 @@ func rewriteFile(path string, snippets map[string]*model.Snippet, dryRun bool) (
     		return false, nil
     	}
     
    +	// Build output by replacing each match's region in left-to-right order.
     	var sb strings.Builder
     	cursor := 0
     	changed := false
    @@ -123,13 +146,26 @@ func rewriteFile(path string, snippets map[string]*model.Snippet, dryRun bool) (
     		if err != nil {
     			return false, fmt.Errorf("snippet %s: %w", s.Path, err)
     		}
    -		rendered := render.RenderForLDApplication(tpl)
    -		jsxBody := wrapForJSX(rendered, src, m.RegionStart, m.RegionEnd)
    +		// Reuse the surrounding whitespace from the existing region so a
    +		// re-render produces a minimal diff. This is purely cosmetic; the
    +		// bare-vs-template decision below is independent and is driven by
    +		// the snippet's intent.
    +		leading, trailing := splitSurroundingWS(src[m.RegionStart:m.RegionEnd])
    +		jsxBody := leading + renderForJSXChild(tpl) + trailing
    +
    +		// Construct the post-rewrite full element (opening tag + new body + closing tag)
    +		// and hash THAT — so attribute edits (e.g. lang="python" → "go") flow
    +		// into the marker hash and are caught by `verify`.
    +		newFullElement := src[m.OpenTagStart:m.RegionStart] + jsxBody + src[m.RegionEnd:m.CloseTagEnd]
    +		newHash := markers.HashContent(newFullElement)
     
     		if dryRun {
    -			actualHash := markers.HashContent(src[m.RegionStart:m.RegionEnd])
    -			if m.Fields.Hash != "" && m.Fields.Hash != actualHash {
    -				return false, fmt.Errorf("marker %q: hand-edit detected — file hash %s does not match marker %s",
    +			if m.Fields.Hash == "" {
    +				return false, fmt.Errorf("marker %q: missing required hash= field — re-render to populate it", m.Fields.ID)
    +			}
    +			actualHash := markers.HashContent(src[m.OpenTagStart:m.CloseTagEnd])
    +			if m.Fields.Hash != actualHash {
    +				return false, fmt.Errorf("marker %q: hand-edit detected — element hash %s does not match marker %s",
     					m.Fields.ID, actualHash, m.Fields.Hash)
     			}
     			if jsxBody != src[m.RegionStart:m.RegionEnd] {
    @@ -140,7 +176,7 @@ func rewriteFile(path string, snippets map[string]*model.Snippet, dryRun bool) (
     
     		newMarker := markers.FormatMarker(m.Style, markers.MarkerFields{
     			ID:      m.Fields.ID,
    -			Hash:    markers.HashContent(jsxBody),
    +			Hash:    newHash,
     			Version: version.Version,
     			Scope:   "content",
     		})
    @@ -161,32 +197,73 @@ func rewriteFile(path string, snippets map[string]*model.Snippet, dryRun bool) (
     	if !changed {
     		return false, nil
     	}
    -	return true, os.WriteFile(path, []byte(sb.String()), 0o644)
    +	return true, atomicWriteFile(path, []byte(sb.String()))
     }
     
    -// wrapForJSX produces the bytes that belong between  and .
    -// To keep cut-over diffs minimal, it preserves the surrounding-whitespace
    -// shape of the existing region:
    -//   - leading whitespace (between `>` and the first non-space char) is reused
    -//   - trailing whitespace (between the last non-space char and ` and .
    +// The choice between bare-text and `{`...`}` template-literal form is made
    +// from the snippet's *intent*, not from what's currently in the file:
    +//   - if the template has any interpolation, conditional, newline, or a
    +//     character JSX would interpret specially (`{`, `}`), wrap in `{`...`}`;
    +//   - otherwise emit bare text, with no JS escaping.
    +//
    +// Escaping for backticks/backslashes/${} only happens when the output is
    +// going to be inside a backtick literal. Bare JSX text doesn't interpret
    +// any of those, so escaping there would corrupt user-visible output.
    +func renderForJSXChild(tpl []render.Node) string {
    +	if needsTemplateLiteral(tpl) {
    +		return "{`" + render.RenderForLDApplicationTemplate(tpl) + "`}"
     	}
    -	return leading + "{`" + rendered + "`}" + trailing
    +	bare, err := render.RenderForJSXText(tpl)
    +	if err != nil {
    +		// Defensive: HasInterpolation should have routed us to the template
    +		// path. If we somehow got here with interpolation, fall back to the
    +		// safe wrapping form.
    +		return "{`" + render.RenderForLDApplicationTemplate(tpl) + "`}"
    +	}
    +	return bare
     }
     
     // splitSurroundingWS returns the leading and trailing whitespace of s.
    @@ -207,3 +284,22 @@ func splitSurroundingWS(s string) (string, string) {
     }
     
     func isSpace(b byte) bool { return b == ' ' || b == '\t' || b == '\n' || b == '\r' }
    +
    +func needsTemplateLiteral(tpl []render.Node) bool {
    +	if render.HasInterpolation(tpl) {
    +		return true
    +	}
    +	for _, n := range tpl {
    +		l, ok := n.(*render.Literal)
    +		if !ok {
    +			continue
    +		}
    +		if strings.Contains(l.Text, "\n") {
    +			return true
    +		}
    +		if render.ContainsJSXSpecial(l.Text) {
    +			return true
    +		}
    +	}
    +	return false
    +}
    diff --git a/snippets/internal/adapters/ldapplication/ldapplication_test.go b/snippets/internal/adapters/ldapplication/ldapplication_test.go
    new file mode 100644
    index 0000000..f6cbe4b
    --- /dev/null
    +++ b/snippets/internal/adapters/ldapplication/ldapplication_test.go
    @@ -0,0 +1,185 @@
    +package ldapplication
    +
    +import (
    +	"os"
    +	"path/filepath"
    +	"strings"
    +	"testing"
    +
    +	"github.com/launchdarkly/sdk-meta/snippets/internal/markers"
    +)
    +
    +// Helper: write a tiny sdks/ tree pointing at a fixture TSX file.
    +func writeSDKTree(t *testing.T, sdksDir, sdkID, getStartedRel, appDir string) {
    +	t.Helper()
    +	d := filepath.Join(sdksDir, sdkID)
    +	if err := os.MkdirAll(filepath.Join(d, "snippets", "getting-started"), 0o755); err != nil {
    +		t.Fatal(err)
    +	}
    +	yaml := "id: " + sdkID + "\n" +
    +		"sdk-meta-id: x\n" +
    +		"display-name: X\n" +
    +		"type: server-side\n" +
    +		"languages:\n  - id: x\n    extensions: [\".x\"]\n" +
    +		"package-managers: [pip]\n" +
    +		"regions: [commercial]\n" +
    +		"hello-world-repo: x/y\n" +
    +		"ld-application:\n  get-started-file: " + getStartedRel + "\n" +
    +		"docs:\n  reference-page: /x\n"
    +	if err := os.WriteFile(filepath.Join(d, "sdk.yaml"), []byte(yaml), 0o644); err != nil {
    +		t.Fatal(err)
    +	}
    +}
    +
    +// Regression for review #3: a sdk.yaml that points get-started-file outside
    +// of appDir must be rejected, not followed.
    +func TestDiscoverTargetFiles_RejectsTraversal(t *testing.T) {
    +	tmp := t.TempDir()
    +	sdks := filepath.Join(tmp, "sdks")
    +	app := filepath.Join(tmp, "app")
    +	if err := os.MkdirAll(app, 0o755); err != nil {
    +		t.Fatal(err)
    +	}
    +	writeSDKTree(t, sdks, "evil-sdk", "../escape.tsx", app)
    +
    +	_, err := discoverTargetFiles(sdks, app)
    +	if err == nil || !strings.Contains(err.Error(), "escapes appDir") {
    +		t.Fatalf("want escapes-appDir error, got %v", err)
    +	}
    +}
    +
    +func TestDiscoverTargetFiles_RejectsAbsolute(t *testing.T) {
    +	tmp := t.TempDir()
    +	sdks := filepath.Join(tmp, "sdks")
    +	app := filepath.Join(tmp, "app")
    +	if err := os.MkdirAll(app, 0o755); err != nil {
    +		t.Fatal(err)
    +	}
    +	writeSDKTree(t, sdks, "evil-sdk", "/etc/passwd", app)
    +
    +	_, err := discoverTargetFiles(sdks, app)
    +	if err == nil || !strings.Contains(err.Error(), "must be relative") {
    +		t.Fatalf("want must-be-relative error, got %v", err)
    +	}
    +}
    +
    +// Regression for review #1: hash should cover attributes, not just children.
    +// Editing `lang="python"` to `lang="go"` must change the hash.
    +func TestFullElementHash_DetectsAttributeEdit(t *testing.T) {
    +	beforeFile := `body`
    +	afterFile := `body`
    +	mBefore, err := markers.ScanTSX("// SDK_SNIPPET:RENDER:foo hash=abc version=0.1.0\n" + beforeFile)
    +	if err != nil {
    +		t.Fatal(err)
    +	}
    +	mAfter, err := markers.ScanTSX("// SDK_SNIPPET:RENDER:foo hash=abc version=0.1.0\n" + afterFile)
    +	if err != nil {
    +		t.Fatal(err)
    +	}
    +	srcBefore := "// SDK_SNIPPET:RENDER:foo hash=abc version=0.1.0\n" + beforeFile
    +	srcAfter := "// SDK_SNIPPET:RENDER:foo hash=abc version=0.1.0\n" + afterFile
    +	hBefore := mBefore[0].FullElementHash(srcBefore)
    +	hAfter := mAfter[0].FullElementHash(srcAfter)
    +	if hBefore == hAfter {
    +		t.Fatalf("expected different hashes; both = %s", hBefore)
    +	}
    +}
    +
    +func TestNeedsTemplateLiteral_BareTextStaysBare(t *testing.T) {
    +	// A snippet with no interpolation, no newline, no JSX-special chars
    +	// should render bare so the existing in-place style is preserved.
    +	tmp := t.TempDir()
    +	sdks := filepath.Join(tmp, "sdks", "x")
    +	if err := os.MkdirAll(filepath.Join(sdks, "snippets"), 0o755); err != nil {
    +		t.Fatal(err)
    +	}
    +	if err := os.WriteFile(filepath.Join(sdks, "sdk.yaml"), []byte(
    +		"id: x\nsdk-meta-id: y\ndisplay-name: y\ntype: server-side\n"+
    +			"languages:\n  - id: y\n    extensions: [\".y\"]\n"+
    +			"package-managers: []\nregions: []\nhello-world-repo: a/b\n"+
    +			"ld-application:\n  get-started-file: app.tsx\n"+
    +			"docs:\n  reference-page: /\n"), 0o644); err != nil {
    +		t.Fatal(err)
    +	}
    +	if err := os.WriteFile(filepath.Join(sdks, "snippets", "x.snippet.md"), []byte(
    +		`---
    +id: x/cmd
    +sdk: x
    +kind: bootstrap
    +lang: shell
    +---
    +
    +`+"```shell\nmkdir hi\n```\n"), 0o644); err != nil {
    +		t.Fatal(err)
    +	}
    +	app := filepath.Join(tmp, "app")
    +	if err := os.MkdirAll(app, 0o755); err != nil {
    +		t.Fatal(err)
    +	}
    +	tsx := `// SDK_SNIPPET:RENDER:x/cmd hash=000000000000 version=0.1.0
    +
    +  placeholder
    +
    +`
    +	if err := os.WriteFile(filepath.Join(app, "app.tsx"), []byte(tsx), 0o644); err != nil {
    +		t.Fatal(err)
    +	}
    +
    +	if _, err := Render(filepath.Join(tmp, "sdks"), app); err != nil {
    +		t.Fatal(err)
    +	}
    +	out, _ := os.ReadFile(filepath.Join(app, "app.tsx"))
    +	// The body must be the bare text, NOT wrapped in {`...`}.
    +	if !strings.Contains(string(out), "\n  mkdir hi\n") {
    +		t.Fatalf("expected bare-text rendering, got:\n%s", out)
    +	}
    +	// And verify must accept it.
    +	if err := Verify(filepath.Join(tmp, "sdks"), app); err != nil {
    +		t.Fatalf("verify after render should pass: %v", err)
    +	}
    +}
    +
    +// Regression for review #5: a marker with no hash= field must be rejected
    +// during verify.
    +func TestVerify_RejectsMissingHash(t *testing.T) {
    +	tmp := t.TempDir()
    +	sdks := filepath.Join(tmp, "sdks", "x")
    +	if err := os.MkdirAll(filepath.Join(sdks, "snippets"), 0o755); err != nil {
    +		t.Fatal(err)
    +	}
    +	if err := os.WriteFile(filepath.Join(sdks, "sdk.yaml"), []byte(
    +		"id: x\nsdk-meta-id: y\ndisplay-name: y\ntype: server-side\n"+
    +			"languages:\n  - id: y\n    extensions: [\".y\"]\n"+
    +			"package-managers: []\nregions: []\nhello-world-repo: a/b\n"+
    +			"ld-application:\n  get-started-file: app.tsx\n"+
    +			"docs:\n  reference-page: /\n"), 0o644); err != nil {
    +		t.Fatal(err)
    +	}
    +	if err := os.WriteFile(filepath.Join(sdks, "snippets", "x.snippet.md"), []byte(
    +		`---
    +id: x/cmd
    +sdk: x
    +kind: bootstrap
    +lang: shell
    +---
    +
    +`+"```shell\nmkdir hi\n```\n"), 0o644); err != nil {
    +		t.Fatal(err)
    +	}
    +	app := filepath.Join(tmp, "app")
    +	if err := os.MkdirAll(app, 0o755); err != nil {
    +		t.Fatal(err)
    +	}
    +	tsx := `// SDK_SNIPPET:RENDER:x/cmd version=0.1.0
    +
    +  mkdir hi
    +
    +`
    +	if err := os.WriteFile(filepath.Join(app, "app.tsx"), []byte(tsx), 0o644); err != nil {
    +		t.Fatal(err)
    +	}
    +	err := Verify(filepath.Join(tmp, "sdks"), app)
    +	if err == nil || !strings.Contains(err.Error(), "missing required hash") {
    +		t.Fatalf("want missing-hash error, got %v", err)
    +	}
    +}
    diff --git a/snippets/internal/markers/markers.go b/snippets/internal/markers/markers.go
    index dc6269b..2e64037 100644
    --- a/snippets/internal/markers/markers.go
    +++ b/snippets/internal/markers/markers.go
    @@ -8,11 +8,18 @@ import (
     	"strings"
     )
     
    -// HashContent returns the first 12 hex chars of the SHA-256 of the bytes.
    +// hashLen is the number of hex characters of the SHA-256 written into render
    +// markers. Twelve hex chars is ~48 bits — enough collision resistance to
    +// catch accidental drift in CI but NOT a cryptographic integrity claim. Do
    +// not extend other security-sensitive checks to rely on this prefix without
    +// widening it first.
    +const hashLen = 12
    +
    +// HashContent returns the first hashLen hex chars of the SHA-256 of the bytes.
     // This is the hash written into render markers.
     func HashContent(content string) string {
     	sum := sha256.Sum256([]byte(content))
    -	return hex.EncodeToString(sum[:])[:12]
    +	return hex.EncodeToString(sum[:])[:hashLen]
     }
     
     // MarkerFields is the set of key=value pairs we recognize after the ID.
    @@ -27,9 +34,9 @@ type MarkerFields struct {
     type commentStyle int
     
     const (
    -	styleLine       commentStyle = iota // `// SDK_SNIPPET:...\n`
    -	styleJSXExpr                         // `{/* SDK_SNIPPET:... */}`
    -	styleBlock                           // `/* SDK_SNIPPET:... */`
    +	styleLine    commentStyle = iota // `// SDK_SNIPPET:...\n`
    +	styleJSXExpr                     // `{/* SDK_SNIPPET:... */}`
    +	styleBlock                       // `/* SDK_SNIPPET:... */`
     )
     
     // Match describes one marker occurrence in a TSX/TS file.
    @@ -48,12 +55,17 @@ type Match struct {
     	TagName string
     }
     
    -// Hash returns the hash of the current content in the element's children.
    -func (m Match) Hash(src string) string {
    -	return HashContent(src[m.RegionStart:m.RegionEnd])
    +// FullElementHash returns the hash of the entire ... region,
    +// including the opening tag's attributes and the closing tag. This is what
    +// render markers commit to so an attribute-only edit (e.g. changing
    +// `lang="python"` → `lang="go"`) is detected by `verify`.
    +func (m Match) FullElementHash(src string) string {
    +	return HashContent(src[m.OpenTagStart:m.CloseTagEnd])
     }
     
     // Regex for the marker line inside any comment syntax. Captures fields.
    +// Only ID is required; hash/version/scope are optional in the regex but the
    +// hash is REQUIRED at verify time (see ldapplication.Verify).
     var markerRe = regexp.MustCompile(
     	`SDK_SNIPPET:RENDER:(?P\S+)` +
     		`(?:\s+hash=(?P[0-9a-fA-F]+))?` +
    @@ -62,6 +74,11 @@ var markerRe = regexp.MustCompile(
     )
     
     // tagNameRe matches the beginning of a JSX component tag: ``
     
    -	// Find matching . First-slice does not support same-tag nesting.
    -	closeTag := ""
    -	closeIdx := strings.Index(src[regionStart:], closeTag)
    -	if closeIdx < 0 {
    -		return Match{}, fmt.Errorf("marker %q: no  closing tag found", m.ID, tag)
    +	// Find matching  at the same nesting depth. Walk the body,
    +	// counting  opens and  closes; the close that
    +	// brings depth back to zero is the match. String/backtick/JSX-expression
    +	// scanning ensures angle brackets inside attributes or string literals
    +	// don't perturb the depth.
    +	openMarker := "<" + tag
    +	closeMarker := ""
    +	tagDepth := 1
    +	regionEnd := -1
    +	closeTagEnd := -1
    +	k := regionStart
    +	for k < len(src) {
    +		switch src[k] {
    +		case '"', '\'':
    +			k = skipPlainString(src, k)
    +			continue
    +		case '`':
    +			k = skipBacktick(src, k)
    +			continue
    +		case '<':
    +			// Same-tag open at depth: only if followed by a non-identifier byte
    +			// (so ` closing tag found", m.ID, tag)
     	}
    -	regionEnd := regionStart + closeIdx
    -	closeTagEnd := regionEnd + len(closeTag)
     
     	return Match{
     		Fields:       m,
    @@ -262,6 +380,24 @@ func attachElement(src string, commentStart, commentEnd int, style commentStyle,
     	}, nil
     }
     
    +// hasTagPrefix reports whether src[i:] starts with prefix AND the byte
    +// immediately after is one that can't be part of a longer identifier.
    +// Used to distinguish `= len(src) {
    +		return true
    +	}
    +	c := src[end]
    +	if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') {
    +		return false
    +	}
    +	return true
    +}
    +
     // FormatMarker renders the marker comment in the given style, with hash+version.
     func FormatMarker(style commentStyle, f MarkerFields) string {
     	body := fmt.Sprintf("SDK_SNIPPET:RENDER:%s hash=%s version=%s", f.ID, f.Hash, f.Version)
    diff --git a/snippets/internal/markers/markers_test.go b/snippets/internal/markers/markers_test.go
    index 88e271b..578468e 100644
    --- a/snippets/internal/markers/markers_test.go
    +++ b/snippets/internal/markers/markers_test.go
    @@ -1,6 +1,9 @@
     package markers
     
    -import "testing"
    +import (
    +	"strings"
    +	"testing"
    +)
     
     func TestScanTSX_LineCommentAndJSXComment(t *testing.T) {
     	src := `import { Snippet } from 'x';
    @@ -54,3 +57,139 @@ func TestScanTSX_IgnoresMarkerInsideString(t *testing.T) {
     		t.Fatalf("expected 0 matches, got %d", len(matches))
     	}
     }
    +
    +func TestScanTSX_NoMarkers(t *testing.T) {
    +	src := `import x from 'y';\nconst A = () => 
    hi
    ;\n` + matches, err := ScanTSX(src) + if err != nil { + t.Fatal(err) + } + if len(matches) != 0 { + t.Fatalf("expected 0 matches, got %d", len(matches)) + } +} + +func TestScanTSX_BlockCommentMarker(t *testing.T) { + src := `const A = ( + /* SDK_SNIPPET:RENDER:foo/bar hash=abc version=0.1.0 */ + body +);` + matches, err := ScanTSX(src) + if err != nil { + t.Fatal(err) + } + if len(matches) != 1 || matches[0].Style != styleBlock { + t.Fatalf("want one block-style match, got %+v", matches) + } +} + +func TestScanTSX_UnterminatedJSXComment(t *testing.T) { + src := `
    {/* SDK_SNIPPET:RENDER:foo hash=abc version=0.1.0` + _, err := ScanTSX(src) + if err == nil { + t.Fatal("want error for unterminated comment") + } +} + +func TestScanTSX_NonWhitespaceBetweenMarkerAndElement(t *testing.T) { + src := `// SDK_SNIPPET:RENDER:foo hash=abc version=0.1.0 +const x = 1; +hi` + _, err := ScanTSX(src) + if err == nil || !strings.Contains(err.Error(), "non-whitespace") { + t.Fatalf("want non-whitespace error, got %v", err) + } +} + +func TestScanTSX_SelfClosingElement(t *testing.T) { + src := `// SDK_SNIPPET:RENDER:foo hash=abc version=0.1.0 +` + _, err := ScanTSX(src) + if err == nil || !strings.Contains(err.Error(), "self-closing") { + t.Fatalf("want self-closing error, got %v", err) + } +} + +func TestScanTSX_MissingClosingTag(t *testing.T) { + src := `// SDK_SNIPPET:RENDER:foo hash=abc version=0.1.0 +only opening` + _, err := ScanTSX(src) + if err == nil || !strings.Contains(err.Error(), "no matching") { + t.Fatalf("want missing-close error, got %v", err) + } +} + +// Regression for review #6: nested same-tag should track depth so the +// outer is matched, not the first inner one. +func TestScanTSX_NestedSameTag(t *testing.T) { + src := `// SDK_SNIPPET:RENDER:foo hash=abc version=0.1.0 +innerafter` + matches, err := ScanTSX(src) + if err != nil { + t.Fatalf("scan: %v", err) + } + if len(matches) != 1 { + t.Fatalf("want 1 match, got %d", len(matches)) + } + body := src[matches[0].RegionStart:matches[0].RegionEnd] + want := `innerafter` + if body != want { + t.Fatalf("body mismatch:\n got: %q\n want: %q", body, want) + } +} + +// `x` + matches, err := ScanTSX(src) + if err != nil { + t.Fatalf("scan: %v", err) + } + if len(matches) != 1 { + t.Fatalf("want 1 match, got %d", len(matches)) + } + body := src[matches[0].RegionStart:matches[0].RegionEnd] + if body != `x` { + t.Fatalf("body mismatch: %q", body) + } +} + +// Regression for review #9: a nested template literal inside a ${ } expression +// inside a backtick string must not end the outer string scan early. +func TestScanTSX_BacktickWithNestedTemplate(t *testing.T) { + src := "const x = `outer ${fn(`inner` + 1)} done`;\n" + + "// SDK_SNIPPET:RENDER:foo hash=abc version=0.1.0\n" + + "body" + matches, err := ScanTSX(src) + if err != nil { + t.Fatalf("scan: %v", err) + } + if len(matches) != 1 || matches[0].Fields.ID != "foo" { + t.Fatalf("want one match for foo, got %+v", matches) + } +} + +// FullElementHash must change when the opening tag's attributes change, so +// `verify` catches an attribute-only edit. Regression for review #1. +func TestFullElementHash_DetectsAttributeEdit(t *testing.T) { + srcA := `body` + srcB := `body` + if HashContent(srcA) == HashContent(srcB) { + t.Fatalf("expected different hashes for differing attributes") + } +} + +func TestParseMarker_OptionalFields(t *testing.T) { + // hash, version, scope all absent: parseMarker still extracts the ID. + got, ok := parseMarker(" SDK_SNIPPET:RENDER:foo/bar") + if !ok || got.ID != "foo/bar" { + t.Fatalf("parse: %+v ok=%v", got, ok) + } + if got.Hash != "" || got.Version != "" { + t.Fatalf("expected empty optional fields: %+v", got) + } + if got.Scope != "content" { + t.Fatalf("default scope should be content: %q", got.Scope) + } +} diff --git a/snippets/internal/model/model.go b/snippets/internal/model/model.go index 4c30bf3..7c974d7 100644 --- a/snippets/internal/model/model.go +++ b/snippets/internal/model/model.go @@ -68,7 +68,9 @@ func ParseFile(path string) (*Snippet, error) { return nil, fmt.Errorf("%s: missing YAML frontmatter", path) } var fm Frontmatter - if err := yaml.Unmarshal(raw[m[2]:m[3]], &fm); err != nil { + dec := yaml.NewDecoder(bytes.NewReader(raw[m[2]:m[3]])) + dec.KnownFields(true) + if err := dec.Decode(&fm); err != nil { return nil, fmt.Errorf("%s: frontmatter parse: %w", path, err) } body := raw[m[1]:] diff --git a/snippets/internal/render/render.go b/snippets/internal/render/render.go index 05a59dd..79abe56 100644 --- a/snippets/internal/render/render.go +++ b/snippets/internal/render/render.go @@ -36,14 +36,29 @@ func RenderRuntime(nodes []Node, inputs map[string]string) (string, error) { return sb.String(), nil } -// RenderForLDApplication produces the body that sits between the surrounding -// TSX backticks for the ld-application adapter. Substitutions become `${name}` -// expressions; conditionals become `${name ? `inner` : ''}` ternaries (nesting -// template literals, which is legal JS since the inner backticks are inside an -// ${} expression). -// -// The caller wraps the result in `...` when embedding in JSX. -func RenderForLDApplication(nodes []Node) string { +// HasInterpolation reports whether the template contains any Var or Cond +// node anywhere in the tree. Used by adapters to decide between bare-text +// and template-literal output forms. +func HasInterpolation(nodes []Node) bool { + for _, n := range nodes { + switch x := n.(type) { + case *Var: + return true + case *Cond: + return true + case *Literal: + _ = x + } + } + return false +} + +// RenderForLDApplicationTemplate produces the body for embedding inside a +// JS template literal: substitutions become `${name}`, conditionals become +// `${name ? `inner` : ''}`. Literal text is escaped so backslashes, +// backticks, and `${` sequences in the source survive into the runtime +// string verbatim. The caller wraps the returned bytes in backticks. +func RenderForLDApplicationTemplate(nodes []Node) string { var sb strings.Builder for _, n := range nodes { switch x := n.(type) { @@ -57,13 +72,40 @@ func RenderForLDApplication(nodes []Node) string { sb.WriteString("${") sb.WriteString(x.Var) sb.WriteString(" ? `") - sb.WriteString(RenderForLDApplication(x.Body)) + sb.WriteString(RenderForLDApplicationTemplate(x.Body)) sb.WriteString("` : ''}") } } return sb.String() } +// RenderForJSXText produces the body for embedding as bare JSX text (i.e. +// not wrapped in a template literal). Only valid when the template has no +// interpolation; the caller is expected to consult HasInterpolation first. +// +// Backslashes, backticks, and ${} sequences are emitted verbatim — JSX +// text does not interpret any of those. Literal `{` and `}` characters +// are not handled here (they would require JSX-specific escaping); the +// caller should fall back to template-literal output if either appears. +func RenderForJSXText(nodes []Node) (string, error) { + var sb strings.Builder + for _, n := range nodes { + l, ok := n.(*Literal) + if !ok { + return "", fmt.Errorf("RenderForJSXText: template has interpolation; use RenderForLDApplicationTemplate") + } + sb.WriteString(l.Text) + } + return sb.String(), nil +} + +// ContainsJSXSpecial reports whether the rendered bare text contains +// characters that JSX would interpret specially (`{` or `}`). When true, +// the caller should switch to template-literal output. +func ContainsJSXSpecial(s string) bool { + return strings.ContainsAny(s, "{}") +} + // escapeTL escapes literal content for inclusion inside a JS template literal. // Order matters: escape backslashes first, then backticks and ${ sequences. func escapeTL(s string) string { diff --git a/snippets/internal/render/template.go b/snippets/internal/render/template.go index 2e5637e..f32e43d 100644 --- a/snippets/internal/render/template.go +++ b/snippets/internal/render/template.go @@ -55,12 +55,16 @@ func Parse(src string) ([]Node, error) { append_(&Literal{Text: src[i:start]}) } token := src[start:end] + // Note: order of cases matters. "if NAME" must be tested first so that a + // hypothetical variable starting with "if" can never be reached. Equality + // is required for the "end" case so a variable like {{ endTime }} doesn't + // get treated as a block-close. switch { case m[4] >= 0: // "if NAME" name := src[m[4]:m[5]] c := &Cond{Var: name} stack = append(stack, c) - case strings.HasPrefix(strings.TrimSpace(token[2:len(token)-2]), "end"): + case strings.TrimSpace(token[2:len(token)-2]) == "end": if len(stack) == 0 { return nil, fmt.Errorf("template: unmatched {{ end }} at offset %d", start) } diff --git a/snippets/internal/render/template_test.go b/snippets/internal/render/template_test.go index b877fc9..b860ab0 100644 --- a/snippets/internal/render/template_test.go +++ b/snippets/internal/render/template_test.go @@ -1,6 +1,9 @@ package render -import "testing" +import ( + "strings" + "testing" +) func TestParseAndRender(t *testing.T) { nodes, err := Parse(`echo "launchdarkly-server-sdk{{ if version }}=={{ version }}{{ end }}" done`) @@ -29,18 +32,118 @@ func TestParseAndRender(t *testing.T) { } // ld-application rendering produces a JS ternary expression - got = RenderForLDApplication(nodes) + got = RenderForLDApplicationTemplate(nodes) want = "echo \"launchdarkly-server-sdk${version ? `==${version}` : ''}\" done" if got != want { t.Fatalf("ld-application: got %q want %q", got, want) } } -func TestRenderForLDApplicationEscapes(t *testing.T) { +func TestRenderForLDApplicationTemplateEscapes(t *testing.T) { nodes, _ := Parse("a \\ b ` c ${d} {{ name }}") - got := RenderForLDApplication(nodes) + got := RenderForLDApplicationTemplate(nodes) want := "a \\\\ b \\` c \\${d} ${name}" if got != want { t.Fatalf("escapes: got %q want %q", got, want) } } + +// Regression for review #4: a variable starting with `end` previously hit a +// HasPrefix check and was treated as a block-close. Now the case requires +// equality so `endTime` is recognized as a normal variable. +func TestEndPrefixedVariable(t *testing.T) { + nodes, err := Parse(`{{ endTime }}`) + if err != nil { + t.Fatalf("parse: %v", err) + } + got, err := RenderRuntime(nodes, map[string]string{"endTime": "23:59"}) + if err != nil { + t.Fatalf("render: %v", err) + } + if got != "23:59" { + t.Fatalf("got %q want 23:59", got) + } +} + +func TestUnmatchedEnd(t *testing.T) { + _, err := Parse(`hello {{ end }}`) + if err == nil || !strings.Contains(err.Error(), "unmatched") { + t.Fatalf("want unmatched-end error, got %v", err) + } +} + +func TestUnclosedIf(t *testing.T) { + _, err := Parse(`{{ if v }}body without end`) + if err == nil || !strings.Contains(err.Error(), "unclosed") { + t.Fatalf("want unclosed-if error, got %v", err) + } +} + +func TestRenderRuntimeUnknownVar(t *testing.T) { + nodes, _ := Parse(`{{ missing }}`) + _, err := RenderRuntime(nodes, map[string]string{}) + if err == nil || !strings.Contains(err.Error(), "missing runtime input") { + t.Fatalf("want missing-input error, got %v", err) + } +} + +func TestRenderRuntimeEmptyTemplate(t *testing.T) { + nodes, err := Parse("") + if err != nil { + t.Fatal(err) + } + got, err := RenderRuntime(nodes, nil) + if err != nil { + t.Fatal(err) + } + if got != "" { + t.Fatalf("want empty, got %q", got) + } +} + +// Regression for review #10: the bare-JSX-text path must not apply +// template-literal escaping. A backslash in the source should round-trip +// verbatim. +func TestRenderForJSXTextNoEscape(t *testing.T) { + nodes, _ := Parse(`python .\main.py`) + got, err := RenderForJSXText(nodes) + if err != nil { + t.Fatalf("render: %v", err) + } + if got != `python .\main.py` { + t.Fatalf("backslash mangled: %q", got) + } +} + +func TestRenderForJSXTextRefusesInterp(t *testing.T) { + nodes, _ := Parse(`hello {{ name }}`) + if _, err := RenderForJSXText(nodes); err == nil { + t.Fatalf("want error when template has interpolation") + } +} + +func TestHasInterpolation(t *testing.T) { + cases := map[string]bool{ + "plain": false, + "hello {{ name }}": true, + "{{ if a }}b{{ end }}": true, + } + for src, want := range cases { + nodes, _ := Parse(src) + if got := HasInterpolation(nodes); got != want { + t.Errorf("HasInterpolation(%q) = %v, want %v", src, got, want) + } + } +} + +func TestContainsJSXSpecial(t *testing.T) { + if !ContainsJSXSpecial("a { b") { + t.Fatal("expected true for `{`") + } + if !ContainsJSXSpecial("a } b") { + t.Fatal("expected true for `}`") + } + if ContainsJSXSpecial("plain text") { + t.Fatal("expected false for plain text") + } +} diff --git a/snippets/internal/validate/validate.go b/snippets/internal/validate/validate.go index d320d65..8701517 100644 --- a/snippets/internal/validate/validate.go +++ b/snippets/internal/validate/validate.go @@ -1,10 +1,14 @@ package validate import ( + "crypto/sha256" + "encoding/hex" "fmt" + "io" "os" "os/exec" "path/filepath" + "strings" "github.com/launchdarkly/sdk-meta/snippets/internal/model" "github.com/launchdarkly/sdk-meta/snippets/internal/render" @@ -70,7 +74,17 @@ func runOne(cfg Config, s *model.Snippet, sdkKey, flagKey string) error { } func runPython(cfg Config, s *model.Snippet, sdkKey, flagKey string) error { - inputs := runtimeInputs(s, flagKey) + if err := checkEntrypoint(s.Frontmatter.Validation.Entrypoint); err != nil { + return err + } + if err := checkRequirements(s.Frontmatter.Validation.Requirements); err != nil { + return err + } + + inputs, err := runtimeInputs(s, sdkKey, flagKey) + if err != nil { + return err + } nodes, err := render.Parse(s.CodeBody) if err != nil { return err @@ -102,8 +116,14 @@ func runPython(cfg Config, s *model.Snippet, sdkKey, flagKey string) error { return fmt.Errorf("validator Dockerfile not found at %s: %w", validatorDir, err) } - imageTag := "sdk-snippets/python-validator:dev" - build := exec.Command("docker", "build", "--quiet", "-t", imageTag, validatorDir) + // Tag the image by hash of its build context. Two concurrent validate runs + // on the same Docker host won't race on a shared mutable tag, and rebuilds + // are skipped automatically when the validator hasn't changed. + tag, err := validatorImageTag(validatorDir) + if err != nil { + return err + } + build := exec.Command("docker", "build", "--quiet", "-t", tag, validatorDir) build.Stdout = os.Stdout build.Stderr = os.Stderr if err := build.Run(); err != nil { @@ -117,7 +137,7 @@ func runPython(cfg Config, s *model.Snippet, sdkKey, flagKey string) error { "-e", "SNIPPET_ENTRYPOINT="+entrypoint, "-e", "LAUNCHDARKLY_SDK_KEY="+sdkKey, "-e", "LAUNCHDARKLY_FLAG_KEY="+flagKey, - imageTag, + tag, ) run.Stdout = os.Stdout run.Stderr = os.Stderr @@ -127,22 +147,98 @@ func runPython(cfg Config, s *model.Snippet, sdkKey, flagKey string) error { return nil } +// checkEntrypoint rejects any value that isn't a plain filename. Snippet +// frontmatter is author-controlled, so without this guard a malicious +// `entrypoint: ../../../etc/foo` would let `os.WriteFile(stageDir+entrypoint)` +// land outside the staging directory. +func checkEntrypoint(entrypoint string) error { + if entrypoint == "" { + return nil + } + if entrypoint != filepath.Base(entrypoint) { + return fmt.Errorf("validation.entrypoint %q must be a plain filename (no path separators or ..)", entrypoint) + } + if entrypoint == "." || entrypoint == ".." { + return fmt.Errorf("validation.entrypoint %q is not a valid filename", entrypoint) + } + return nil +} + +// checkRequirements rejects values that would let a snippet author smuggle +// pip flags through the requirements.txt that the validator writes. The +// allow-list is "one or more requirement specifiers, separated by single +// newlines, none starting with `-`". This blocks `--extra-index-url` / +// `--index-url` / `-r other.txt` style escapes. +func checkRequirements(req string) error { + if req == "" { + return nil + } + for i, line := range strings.Split(req, "\n") { + trim := strings.TrimSpace(line) + if trim == "" { + continue + } + if strings.HasPrefix(trim, "-") { + return fmt.Errorf("validation.requirements line %d %q starts with '-' (pip flags are not allowed)", i+1, trim) + } + } + return nil +} + +// validatorImageTag produces a Docker tag that's a content hash of the +// validator directory: a deterministic tag that changes only when a file in +// the validator changes. Concurrent validate runs against the same validator +// thus reuse the cached image; runs against different validators get different +// tags so they cannot interleave. +func validatorImageTag(dir string) (string, error) { + h := sha256.New() + err := filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + f, err := os.Open(p) + if err != nil { + return err + } + defer f.Close() + rel, _ := filepath.Rel(dir, p) + fmt.Fprintf(h, "%s\x00", rel) + _, err = io.Copy(h, f) + return err + }) + if err != nil { + return "", err + } + return "sdk-snippets/python-validator:" + hex.EncodeToString(h.Sum(nil))[:16], nil +} + // runtimeInputs derives concrete values for every declared input. -// Inputs typed as `flag-key` use the LAUNCHDARKLY_FLAG_KEY env value. Inputs -// typed as `sdk-key` use the LAUNCHDARKLY_SDK_KEY env value (the snippet's -// rendered source needs the literal key when it's interpolated, e.g. in the -// `Run` shell command — but the Python `main.py` reads it from the env and -// never has the key in its source). Other inputs fall back to the snippet's -// own runtime-default. -func runtimeInputs(s *model.Snippet, flagKey string) map[string]string { +// +// Inputs typed as `flag-key` use LAUNCHDARKLY_FLAG_KEY; inputs typed as +// `sdk-key` use LAUNCHDARKLY_SDK_KEY. Both come from the caller's env so the +// snippet's rendered output never embeds a real key. Other inputs fall back +// to the snippet's own runtime-default. Declaring runtime-default for either +// keyed type is an error: the value must always come from the environment. +func runtimeInputs(s *model.Snippet, sdkKey, flagKey string) (map[string]string, error) { out := map[string]string{} for name, in := range s.Frontmatter.Inputs { switch in.Type { case "flag-key": + if in.RuntimeDefault != "" { + return nil, fmt.Errorf("input %q (type=flag-key) must not declare runtime-default — value comes from LAUNCHDARKLY_FLAG_KEY", name) + } out[name] = flagKey + case "sdk-key": + if in.RuntimeDefault != "" { + return nil, fmt.Errorf("input %q (type=sdk-key) must not declare runtime-default — value comes from LAUNCHDARKLY_SDK_KEY", name) + } + out[name] = sdkKey default: out[name] = in.RuntimeDefault } } - return out + return out, nil } diff --git a/snippets/internal/validate/validate_test.go b/snippets/internal/validate/validate_test.go new file mode 100644 index 0000000..ca3ad58 --- /dev/null +++ b/snippets/internal/validate/validate_test.go @@ -0,0 +1,105 @@ +package validate + +import ( + "strings" + "testing" + + "github.com/launchdarkly/sdk-meta/snippets/internal/model" +) + +// Regression for review #2: snippet author-controlled values may not escape +// the staging directory. +func TestCheckEntrypointRejectsTraversal(t *testing.T) { + bad := []string{ + "../etc/passwd", + "../../home/x/.ssh/authorized_keys", + "a/b/c.py", + "./main.py", + ".", + "..", + } + for _, e := range bad { + if err := checkEntrypoint(e); err == nil { + t.Errorf("checkEntrypoint(%q): expected error", e) + } + } + good := []string{"main.py", "app.py", "snippet_test.py"} + for _, e := range good { + if err := checkEntrypoint(e); err != nil { + t.Errorf("checkEntrypoint(%q): unexpected error %v", e, err) + } + } + // Empty is allowed (no validation entrypoint declared). + if err := checkEntrypoint(""); err != nil { + t.Errorf("empty entrypoint should be allowed: %v", err) + } +} + +// Regression for review #8: pip flag injection via requirements.txt. +func TestCheckRequirementsRejectsPipFlags(t *testing.T) { + bad := []string{ + "--extra-index-url=https://evil.example/pypi", + "--index-url https://evil.example/pypi", + "-r other-requirements.txt", + "requests\n--extra-index-url=https://evil.example/pypi", + } + for _, r := range bad { + err := checkRequirements(r) + if err == nil || !strings.Contains(err.Error(), "pip flags") { + t.Errorf("checkRequirements(%q): want pip-flag error, got %v", r, err) + } + } + good := []string{ + "launchdarkly-server-sdk", + "launchdarkly-server-sdk==9.2.0", + "requests\nlaunchdarkly-server-sdk", + } + for _, r := range good { + if err := checkRequirements(r); err != nil { + t.Errorf("checkRequirements(%q): unexpected error %v", r, err) + } + } +} + +// Regression for review #7: sdk-key inputs must come from the env, not from +// a snippet's runtime-default. +func TestRuntimeInputs(t *testing.T) { + s := &model.Snippet{ + Frontmatter: model.Frontmatter{ + Inputs: map[string]model.Input{ + "apiKey": {Type: "sdk-key"}, + "featureKey": {Type: "flag-key"}, + "version": {Type: "string", RuntimeDefault: "1.2.3"}, + }, + }, + } + got, err := runtimeInputs(s, "real-sdk-key", "real-flag-key") + if err != nil { + t.Fatal(err) + } + if got["apiKey"] != "real-sdk-key" { + t.Errorf("apiKey: got %q want real-sdk-key", got["apiKey"]) + } + if got["featureKey"] != "real-flag-key" { + t.Errorf("featureKey: got %q want real-flag-key", got["featureKey"]) + } + if got["version"] != "1.2.3" { + t.Errorf("version: got %q want 1.2.3", got["version"]) + } +} + +func TestRuntimeInputsRejectsRuntimeDefaultOnKeys(t *testing.T) { + for _, kind := range []string{"sdk-key", "flag-key"} { + s := &model.Snippet{ + Frontmatter: model.Frontmatter{ + Inputs: map[string]model.Input{ + "k": {Type: kind, RuntimeDefault: "should-not-be-here"}, + }, + }, + } + _, err := runtimeInputs(s, "x", "y") + if err == nil || !strings.Contains(err.Error(), "runtime-default") { + t.Errorf("%s: want runtime-default rejection, got %v", kind, err) + } + } +} diff --git a/snippets/validators/languages/python/harness/run.sh b/snippets/validators/languages/python/harness/run.sh index 51f335b..0ad6fb0 100755 --- a/snippets/validators/languages/python/harness/run.sh +++ b/snippets/validators/languages/python/harness/run.sh @@ -52,6 +52,9 @@ done kill -TERM "$PID" 2>/dev/null || true wait "$PID" 2>/dev/null || true echo "validator: did not see expected line: ${prefix}" >&2 -echo "--- snippet output ---" >&2 -cat "$LOG" >&2 +echo "--- snippet output (LAUNCHDARKLY_SDK_KEY redacted) ---" >&2 +# Defense-in-depth: today's snippets never print LAUNCHDARKLY_SDK_KEY, but a +# future snippet could (intentionally or by accident) and this log gets piped +# into CI output. Redact any literal occurrence of the key before dumping. +sed -e "s|${LAUNCHDARKLY_SDK_KEY}||g" "$LOG" >&2 exit 1 From fc2fd8249a566d46b094280e4a94903609302827 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:08:22 -0700 Subject: [PATCH 3/4] fix(snippets): hash only the children of marked elements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walk back the part of the prior review-feedback commit that hashed the full region. The scope=content contract says the consumer owns the element's attributes; locking them down forces a re-render every time someone tweaks `withCopyButton` / `label="…"` / `className` and offers nothing the design promised. `verify` now: - requires a hash= field on every marker (#5, unchanged) - compares it against the SHA-256 of src[RegionStart:RegionEnd] (just the children, as originally documented) - accepts attribute-only edits, rejects child edits Tests updated: TestVerify_AcceptsAttributeEdit and TestVerify_RejectsChildEdit pin the new contract; the dead TestFullElementHash_DetectsAttributeEdit is removed; the now-unused Match.FullElementHash method is dropped. End-to-end re-checked: render is idempotent, verify ok, an attribute edit passes verify, a child edit fails verify with a children-hash error, and `snippets validate` against a real LD environment still prints `*** The sample-feature feature flag evaluates to True`. --- snippets/docs/AUTHORING.md | 7 +- .../adapters/ldapplication/ldapplication.go | 14 +-- .../ldapplication/ldapplication_test.go | 117 +++++++++++++++--- snippets/internal/markers/markers.go | 8 -- snippets/internal/markers/markers_test.go | 10 -- 5 files changed, 111 insertions(+), 45 deletions(-) diff --git a/snippets/docs/AUTHORING.md b/snippets/docs/AUTHORING.md index 8ad59e9..015dbc4 100644 --- a/snippets/docs/AUTHORING.md +++ b/snippets/docs/AUTHORING.md @@ -53,9 +53,10 @@ whose body should be replaced: The element directly following a marker MUST be a capitalized JSX component tag (e.g. ``, ``). Lowercase HTML tags (`
    `, ``)
     are not recognized; wrap the content in a component first if you need to mark
    -it. The hash committed by the marker covers the *entire* ``
    -region — opening tag, attributes, body, closing tag — so attribute edits like
    -`lang="python"` → `lang="go"` are caught by `verify`.
    +it. The marker hash covers only the *children* of the element (between `>`
    +and `body`
    -	afterFile := `body`
    -	mBefore, err := markers.ScanTSX("// SDK_SNIPPET:RENDER:foo hash=abc version=0.1.0\n" + beforeFile)
    -	if err != nil {
    +// Per the scope=content contract, attributes are the consumer's to choose.
    +// `verify` must NOT reject an attribute-only edit — only changes to the
    +// element's children should fail. Tests below exercise both cases.
    +func TestVerify_AcceptsAttributeEdit(t *testing.T) {
    +	tmp := t.TempDir()
    +	sdks := filepath.Join(tmp, "sdks", "x")
    +	if err := os.MkdirAll(filepath.Join(sdks, "snippets"), 0o755); err != nil {
    +		t.Fatal(err)
    +	}
    +	if err := os.WriteFile(filepath.Join(sdks, "sdk.yaml"), []byte(
    +		"id: x\nsdk-meta-id: y\ndisplay-name: y\ntype: server-side\n"+
    +			"languages:\n  - id: y\n    extensions: [\".y\"]\n"+
    +			"package-managers: []\nregions: []\nhello-world-repo: a/b\n"+
    +			"ld-application:\n  get-started-file: app.tsx\n"+
    +			"docs:\n  reference-page: /\n"), 0o644); err != nil {
    +		t.Fatal(err)
    +	}
    +	if err := os.WriteFile(filepath.Join(sdks, "snippets", "x.snippet.md"), []byte(
    +		`---
    +id: x/cmd
    +sdk: x
    +kind: bootstrap
    +lang: shell
    +---
    +
    +`+"```shell\nmkdir hi\n```\n"), 0o644); err != nil {
    +		t.Fatal(err)
    +	}
    +	app := filepath.Join(tmp, "app")
    +	if err := os.MkdirAll(app, 0o755); err != nil {
    +		t.Fatal(err)
    +	}
    +	tsx := `// SDK_SNIPPET:RENDER:x/cmd hash=000000000000 version=0.1.0
    +
    +  placeholder
    +
    +`
    +	if err := os.WriteFile(filepath.Join(app, "app.tsx"), []byte(tsx), 0o644); err != nil {
    +		t.Fatal(err)
    +	}
    +	if _, err := Render(filepath.Join(tmp, "sdks"), app); err != nil {
    +		t.Fatal(err)
    +	}
    +	// Hand-edit an attribute (add withCopyButton). Verify must still pass.
    +	bytes, _ := os.ReadFile(filepath.Join(app, "app.tsx"))
    +	edited := strings.Replace(string(bytes),
    +		``,
    +		``, 1)
    +	if err := os.WriteFile(filepath.Join(app, "app.tsx"), []byte(edited), 0o644); err != nil {
    +		t.Fatal(err)
    +	}
    +	if err := Verify(filepath.Join(tmp, "sdks"), app); err != nil {
    +		t.Fatalf("verify should accept attribute-only edit: %v", err)
    +	}
    +}
    +
    +func TestVerify_RejectsChildEdit(t *testing.T) {
    +	tmp := t.TempDir()
    +	sdks := filepath.Join(tmp, "sdks", "x")
    +	if err := os.MkdirAll(filepath.Join(sdks, "snippets"), 0o755); err != nil {
    +		t.Fatal(err)
    +	}
    +	if err := os.WriteFile(filepath.Join(sdks, "sdk.yaml"), []byte(
    +		"id: x\nsdk-meta-id: y\ndisplay-name: y\ntype: server-side\n"+
    +			"languages:\n  - id: y\n    extensions: [\".y\"]\n"+
    +			"package-managers: []\nregions: []\nhello-world-repo: a/b\n"+
    +			"ld-application:\n  get-started-file: app.tsx\n"+
    +			"docs:\n  reference-page: /\n"), 0o644); err != nil {
    +		t.Fatal(err)
    +	}
    +	if err := os.WriteFile(filepath.Join(sdks, "snippets", "x.snippet.md"), []byte(
    +		`---
    +id: x/cmd
    +sdk: x
    +kind: bootstrap
    +lang: shell
    +---
    +
    +`+"```shell\nmkdir hi\n```\n"), 0o644); err != nil {
     		t.Fatal(err)
     	}
    -	mAfter, err := markers.ScanTSX("// SDK_SNIPPET:RENDER:foo hash=abc version=0.1.0\n" + afterFile)
    -	if err != nil {
    +	app := filepath.Join(tmp, "app")
    +	if err := os.MkdirAll(app, 0o755); err != nil {
     		t.Fatal(err)
     	}
    -	srcBefore := "// SDK_SNIPPET:RENDER:foo hash=abc version=0.1.0\n" + beforeFile
    -	srcAfter := "// SDK_SNIPPET:RENDER:foo hash=abc version=0.1.0\n" + afterFile
    -	hBefore := mBefore[0].FullElementHash(srcBefore)
    -	hAfter := mAfter[0].FullElementHash(srcAfter)
    -	if hBefore == hAfter {
    -		t.Fatalf("expected different hashes; both = %s", hBefore)
    +	tsx := `// SDK_SNIPPET:RENDER:x/cmd hash=000000000000 version=0.1.0
    +
    +  placeholder
    +
    +`
    +	if err := os.WriteFile(filepath.Join(app, "app.tsx"), []byte(tsx), 0o644); err != nil {
    +		t.Fatal(err)
    +	}
    +	if _, err := Render(filepath.Join(tmp, "sdks"), app); err != nil {
    +		t.Fatal(err)
    +	}
    +	bytes, _ := os.ReadFile(filepath.Join(app, "app.tsx"))
    +	edited := strings.Replace(string(bytes), "mkdir hi", "mkdir HACKED", 1)
    +	if err := os.WriteFile(filepath.Join(app, "app.tsx"), []byte(edited), 0o644); err != nil {
    +		t.Fatal(err)
    +	}
    +	err := Verify(filepath.Join(tmp, "sdks"), app)
    +	if err == nil || !strings.Contains(err.Error(), "hand-edit detected") {
    +		t.Fatalf("verify should reject child edit, got %v", err)
     	}
     }
     
    diff --git a/snippets/internal/markers/markers.go b/snippets/internal/markers/markers.go
    index 2e64037..4a48e49 100644
    --- a/snippets/internal/markers/markers.go
    +++ b/snippets/internal/markers/markers.go
    @@ -55,14 +55,6 @@ type Match struct {
     	TagName string
     }
     
    -// FullElementHash returns the hash of the entire ... region,
    -// including the opening tag's attributes and the closing tag. This is what
    -// render markers commit to so an attribute-only edit (e.g. changing
    -// `lang="python"` → `lang="go"`) is detected by `verify`.
    -func (m Match) FullElementHash(src string) string {
    -	return HashContent(src[m.OpenTagStart:m.CloseTagEnd])
    -}
    -
     // Regex for the marker line inside any comment syntax. Captures fields.
     // Only ID is required; hash/version/scope are optional in the regex but the
     // hash is REQUIRED at verify time (see ldapplication.Verify).
    diff --git a/snippets/internal/markers/markers_test.go b/snippets/internal/markers/markers_test.go
    index 578468e..ffbb7a2 100644
    --- a/snippets/internal/markers/markers_test.go
    +++ b/snippets/internal/markers/markers_test.go
    @@ -170,16 +170,6 @@ func TestScanTSX_BacktickWithNestedTemplate(t *testing.T) {
     	}
     }
     
    -// FullElementHash must change when the opening tag's attributes change, so
    -// `verify` catches an attribute-only edit. Regression for review #1.
    -func TestFullElementHash_DetectsAttributeEdit(t *testing.T) {
    -	srcA := `body`
    -	srcB := `body`
    -	if HashContent(srcA) == HashContent(srcB) {
    -		t.Fatalf("expected different hashes for differing attributes")
    -	}
    -}
    -
     func TestParseMarker_OptionalFields(t *testing.T) {
     	// hash, version, scope all absent: parseMarker still extracts the ID.
     	got, ok := parseMarker(" SDK_SNIPPET:RENDER:foo/bar")
    
    From b8d82b405808aef2100b9983336f3f16da7660a7 Mon Sep 17 00:00:00 2001
    From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com>
    Date: Tue, 28 Apr 2026 08:54:38 -0700
    Subject: [PATCH 4/4] refactor(snippets): port multi-SDK tooling improvements
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    Brings the generator-side improvements developed on the
    port-remaining-getting-started branch back to this branch so the
    tooling lives with the original framework slice. The port branch will
    rebase on top of this and carry only snippet content + validators +
    CI.
    
    What lands here (no new SDKs or validators outside python):
    
    - internal/validate/runner.go (new) + restructured validate.go.
      Per-runtime dispatcher driven by validators/languages//runner.yaml
      (mode: docker | native, runs-on hint, image-prefix). Snippets pick a
      validator via `validation.runtime`; the dispatcher stages the snippet
      body plus any `validation.companions:` files at their `file:` paths,
      then either docker-builds + runs the language image or execs the
      harness directly. Build context is now the entire `validators/` dir
      so each Dockerfile can pull from `shared/` alongside its own
      `languages//`.
    
    - envInputs covers the full EXAM-HELLO env-var set
      (LAUNCHDARKLY_SDK_KEY / FLAG_KEY / MOBILE_KEY / CLIENT_SIDE_ID).
      runtimeInputs has arms for sdk-key, flag-key, mobile-key, and
      client-side-id; declaring runtime-default for any of those is an
      error. requireEnvForInputs fails fast with a clear message before a
      pip install or docker build burns time.
    
    - internal/model/model.go: Validation gains Runtime + Companions
      fields. Frontmatter still loaded with KnownFields(true).
    
    - internal/render/render.go: render functions now take a declared-
      inputs set. `{{ name }}` for a name not in the set passes through
      as literal `{{ name }}` so foreign template syntax (e.g. Vue's
      `{{ flagValue }}` mustaches in a Vue snippet body) survives both
      runtime substitution and ld-application rendering. Conditionals
      still require declared inputs (Vue uses `v-if`, not `{{ if … }}`).
    
    - internal/markers/markers.go: skipPlainString returns i+1 when no
      closing quote precedes the next newline — fixes the case where an
      apostrophe in JSX text (`SDK's shared libraries`) was eaten as the
      start of a string literal and the scanner skipped past following
      render markers. Multi-line strings use backticks (handled by
      skipBacktick), so this is safe.
    
    - internal/adapters/ldapplication/ldapplication.go: threads the
      declared-input set through renderForJSXChild so the bare-vs-template
      decision and the foreign-template pass-through both work.
    
    - validators/shared/lib.sh (new): shared harness helpers
      (require_env, await_success_line, dump_redacted, fail_with_log).
      Each per-language harness will source it for the polling/timeout
      loop and key-redacting log dump.
    
    - validators/languages/python/: Dockerfile rebased on the new build
      context (COPY shared /harness-shared, COPY languages/python/harness
      /harness). harness/run.sh sources the shared lib. New runner.yaml
      declaring mode: docker.
    
    Verified: go build / go test clean; snippets render --target=ld-
    application --out= against the python-only state on this
    branch is idempotent and verify passes.
    ---
     .../adapters/ldapplication/ldapplication.go   |  35 +-
     snippets/internal/markers/markers.go          |  22 +-
     snippets/internal/model/model.go              |  28 +-
     snippets/internal/render/render.go            |  70 ++-
     snippets/internal/render/template_test.go     |  93 ++--
     snippets/internal/validate/runner.go          |  53 +++
     snippets/internal/validate/validate.go        | 420 +++++++++++++-----
     snippets/internal/validate/validate_test.go   |  70 +--
     .../validators/languages/python/Dockerfile    |   6 +-
     .../languages/python/harness/run.sh           |  52 +--
     .../validators/languages/python/runner.yaml   |  13 +
     snippets/validators/shared/lib.sh             |  83 ++++
     12 files changed, 692 insertions(+), 253 deletions(-)
     create mode 100644 snippets/internal/validate/runner.go
     create mode 100644 snippets/validators/languages/python/runner.yaml
     create mode 100644 snippets/validators/shared/lib.sh
    
    diff --git a/snippets/internal/adapters/ldapplication/ldapplication.go b/snippets/internal/adapters/ldapplication/ldapplication.go
    index 88ada74..2fc75e5 100644
    --- a/snippets/internal/adapters/ldapplication/ldapplication.go
    +++ b/snippets/internal/adapters/ldapplication/ldapplication.go
    @@ -146,12 +146,13 @@ func rewriteFile(path string, snippets map[string]*model.Snippet, dryRun bool) (
     		if err != nil {
     			return false, fmt.Errorf("snippet %s: %w", s.Path, err)
     		}
    +		declared := declaredInputSet(s)
     		// Reuse the surrounding whitespace from the existing region so a
     		// re-render produces a minimal diff. This is purely cosmetic; the
     		// bare-vs-template decision below is independent and is driven by
     		// the snippet's intent.
     		leading, trailing := splitSurroundingWS(src[m.RegionStart:m.RegionEnd])
    -		jsxBody := leading + renderForJSXChild(tpl) + trailing
    +		jsxBody := leading + renderForJSXChild(tpl, declared) + trailing
     
     		// The hash covers ONLY the children we own. Attributes on the
     		// element are the consumer's to choose (lang="…", withCopyButton,
    @@ -252,20 +253,32 @@ func atomicWriteFile(path string, data []byte) error {
     // Escaping for backticks/backslashes/${} only happens when the output is
     // going to be inside a backtick literal. Bare JSX text doesn't interpret
     // any of those, so escaping there would corrupt user-visible output.
    -func renderForJSXChild(tpl []render.Node) string {
    -	if needsTemplateLiteral(tpl) {
    -		return "{`" + render.RenderForLDApplicationTemplate(tpl) + "`}"
    +func renderForJSXChild(tpl []render.Node, declared map[string]struct{}) string {
    +	if needsTemplateLiteral(tpl, declared) {
    +		return "{`" + render.RenderForLDApplicationTemplate(tpl, declared) + "`}"
     	}
    -	bare, err := render.RenderForJSXText(tpl)
    +	bare, err := render.RenderForJSXText(tpl, declared)
     	if err != nil {
    -		// Defensive: HasInterpolation should have routed us to the template
    -		// path. If we somehow got here with interpolation, fall back to the
    -		// safe wrapping form.
    -		return "{`" + render.RenderForLDApplicationTemplate(tpl) + "`}"
    +		// Defensive: needsTemplateLiteral should have routed us to the
    +		// template path. If we somehow got here with interpolation, fall
    +		// back to the safe wrapping form.
    +		return "{`" + render.RenderForLDApplicationTemplate(tpl, declared) + "`}"
     	}
     	return bare
     }
     
    +// declaredInputSet returns the set of input names declared on the snippet's
    +// frontmatter. Used to differentiate `{{ name }}` we own (declared inputs)
    +// from foreign template syntax (e.g. Vue's `{{ flagValue }}` mustaches in a
    +// Vue snippet body) that should pass through verbatim.
    +func declaredInputSet(s *model.Snippet) map[string]struct{} {
    +	out := make(map[string]struct{}, len(s.Frontmatter.Inputs))
    +	for name := range s.Frontmatter.Inputs {
    +		out[name] = struct{}{}
    +	}
    +	return out
    +}
    +
     // splitSurroundingWS returns the leading and trailing whitespace of s.
     // If s is all whitespace, the leading captures it and trailing is empty.
     func splitSurroundingWS(s string) (string, string) {
    @@ -285,8 +298,8 @@ func splitSurroundingWS(s string) (string, string) {
     
     func isSpace(b byte) bool { return b == ' ' || b == '\t' || b == '\n' || b == '\r' }
     
    -func needsTemplateLiteral(tpl []render.Node) bool {
    -	if render.HasInterpolation(tpl) {
    +func needsTemplateLiteral(tpl []render.Node, declared map[string]struct{}) bool {
    +	if render.HasInterpolation(tpl, declared) {
     		return true
     	}
     	for _, n := range tpl {
    diff --git a/snippets/internal/markers/markers.go b/snippets/internal/markers/markers.go
    index 4a48e49..515a6d4 100644
    --- a/snippets/internal/markers/markers.go
    +++ b/snippets/internal/markers/markers.go
    @@ -159,21 +159,33 @@ func ScanTSX(src string) ([]Match, error) {
     
     // skipPlainString consumes a "..." or '...' string literal starting at i and
     // returns the offset immediately after the closing quote. Backslash escapes
    -// are honored. If the string is unterminated the function returns len(src).
    +// are honored.
    +//
    +// If the apparent string crosses a newline before finding a closing quote,
    +// the function treats the opening character as NOT a string start (returns
    +// i+1 so the caller advances one byte). This guards against JSX text content
    +// like `SDK's shared libraries` where an apostrophe is part of a word, not
    +// a string-literal opener — without this, the scanner would swallow
    +// hundreds of bytes and miss the next marker. Multi-line strings in JS/JSX
    +// use backticks, which skipBacktick handles separately.
     func skipPlainString(src string, i int) int {
     	quote := src[i]
     	j := i + 1
     	for j < len(src) {
    -		if src[j] == '\\' {
    +		switch src[j] {
    +		case '\\':
     			j += 2
     			continue
    -		}
    -		if src[j] == quote {
    +		case '\n':
    +			// Quote didn't close on this line — assume it wasn't a string
    +			// opener (e.g. a JSX-text apostrophe).
    +			return i + 1
    +		case quote:
     			return j + 1
     		}
     		j++
     	}
    -	return len(src)
    +	return i + 1
     }
     
     // skipBacktick consumes a `...` template literal starting at i and returns
    diff --git a/snippets/internal/model/model.go b/snippets/internal/model/model.go
    index 7c974d7..d03022f 100644
    --- a/snippets/internal/model/model.go
    +++ b/snippets/internal/model/model.go
    @@ -39,8 +39,34 @@ type LDApplicationHints struct {
     }
     
     type Validation struct {
    -	Entrypoint   string `yaml:"entrypoint"`
    +	// Runtime selects the validator harness under
    +	// validators/languages//. If empty, the snippet's `lang:`
    +	// field is used as the fallback (e.g. lang=python implies the python
    +	// harness). Set explicitly when the snippet's lang doesn't equal the
    +	// runtime — e.g. `lang: javascript` snippets that run under Node use
    +	// `runtime: node`, and `lang: html` snippets that run in a headless
    +	// browser use `runtime: browser`.
    +	Runtime string `yaml:"runtime"`
    +
    +	// Entrypoint is the relative file path the harness invokes. If empty,
    +	// the snippet's `file:` field is used (which is also where the rendered
    +	// body is staged). Required for the validator to consider the snippet
    +	// runnable; absence means "no validation for this snippet."
    +	Entrypoint string `yaml:"entrypoint"`
    +
    +	// Requirements is a runtime-specific dependency descriptor. For Python
    +	// it's the contents of requirements.txt. For other runtimes it's
    +	// language-specific (or empty when manifest companions carry deps).
     	Requirements string `yaml:"requirements"`
    +
    +	// Companions lists snippet IDs to stage alongside this one. Each
    +	// companion's body is rendered with the same runtime inputs and
    +	// written to the staging dir at the companion's `file:` path. Use for
    +	// multi-file projects (Java pom.xml, .NET .csproj, Rust Cargo.toml,
    +	// iOS Podfile, etc.). The companions need not declare their own
    +	// `validation.runtime` — they just declare `file:` so the validator
    +	// knows where to put them.
    +	Companions []string `yaml:"companions"`
     }
     
     // Snippet pairs the frontmatter with the body of the first fenced code block
    diff --git a/snippets/internal/render/render.go b/snippets/internal/render/render.go
    index 79abe56..5c41b4c 100644
    --- a/snippets/internal/render/render.go
    +++ b/snippets/internal/render/render.go
    @@ -6,7 +6,9 @@ import (
     )
     
     // RenderRuntime substitutes inputs as concrete values. Used by the validator.
    -// A missing input is an error — validation must always have a value.
    +// Names that don't appear in the inputs map round-trip as literal `{{ name }}`
    +// so foreign template languages embedded in the snippet body (e.g. Vue's
    +// own `{{ ... }}` mustache syntax) survive validation untouched.
     func RenderRuntime(nodes []Node, inputs map[string]string) (string, error) {
     	var sb strings.Builder
     	for _, n := range nodes {
    @@ -16,13 +18,20 @@ func RenderRuntime(nodes []Node, inputs map[string]string) (string, error) {
     		case *Var:
     			v, ok := inputs[x.Name]
     			if !ok {
    -				return "", fmt.Errorf("render: missing runtime input %q", x.Name)
    +				// Unknown name — emit verbatim so foreign templates pass through.
    +				sb.WriteString("{{ ")
    +				sb.WriteString(x.Name)
    +				sb.WriteString(" }}")
    +				continue
     			}
     			sb.WriteString(v)
     		case *Cond:
     			v, ok := inputs[x.Var]
     			if !ok {
    -				return "", fmt.Errorf("render: missing runtime input %q (used in conditional)", x.Var)
    +				// We only support {{ if name }}…{{ end }} for declared inputs.
    +				// An undeclared name in a conditional is almost certainly an
    +				// authoring mistake (Vue's `v-if` is a different syntax).
    +				return "", fmt.Errorf("render: conditional refers to undeclared input %q", x.Var)
     			}
     			if v != "" {
     				inner, err := RenderRuntime(x.Body, inputs)
    @@ -36,18 +45,19 @@ func RenderRuntime(nodes []Node, inputs map[string]string) (string, error) {
     	return sb.String(), nil
     }
     
    -// HasInterpolation reports whether the template contains any Var or Cond
    -// node anywhere in the tree. Used by adapters to decide between bare-text
    -// and template-literal output forms.
    -func HasInterpolation(nodes []Node) bool {
    +// HasInterpolation reports whether the template contains any Var (matching
    +// a declared input) or Cond node. Used by adapters to decide between bare-
    +// text and template-literal output forms. Foreign-template Vars (names
    +// not in `declaredInputs`) don't count — they're literal text.
    +func HasInterpolation(nodes []Node, declaredInputs map[string]struct{}) bool {
     	for _, n := range nodes {
     		switch x := n.(type) {
     		case *Var:
    -			return true
    +			if _, ok := declaredInputs[x.Name]; ok {
    +				return true
    +			}
     		case *Cond:
     			return true
    -		case *Literal:
    -			_ = x
     		}
     	}
     	return false
    @@ -57,14 +67,22 @@ func HasInterpolation(nodes []Node) bool {
     // JS template literal: substitutions become `${name}`, conditionals become
     // `${name ? `inner` : ''}`. Literal text is escaped so backslashes,
     // backticks, and `${` sequences in the source survive into the runtime
    -// string verbatim. The caller wraps the returned bytes in backticks.
    -func RenderForLDApplicationTemplate(nodes []Node) string {
    +// string verbatim. Names not in declaredInputs are emitted as literal
    +// `{{ name }}` so foreign-template syntax (Vue's mustaches) passes through.
    +// The caller wraps the returned bytes in backticks.
    +func RenderForLDApplicationTemplate(nodes []Node, declaredInputs map[string]struct{}) string {
     	var sb strings.Builder
     	for _, n := range nodes {
     		switch x := n.(type) {
     		case *Literal:
     			sb.WriteString(escapeTL(x.Text))
     		case *Var:
    +			if _, ok := declaredInputs[x.Name]; !ok {
    +				// Foreign template — emit the original `{{ name }}` literally,
    +				// escaped for the surrounding template literal.
    +				sb.WriteString(escapeTL("{{ " + x.Name + " }}"))
    +				continue
    +			}
     			sb.WriteString("${")
     			sb.WriteString(x.Name)
     			sb.WriteString("}")
    @@ -72,7 +90,7 @@ func RenderForLDApplicationTemplate(nodes []Node) string {
     			sb.WriteString("${")
     			sb.WriteString(x.Var)
     			sb.WriteString(" ? `")
    -			sb.WriteString(RenderForLDApplicationTemplate(x.Body))
    +			sb.WriteString(RenderForLDApplicationTemplate(x.Body, declaredInputs))
     			sb.WriteString("` : ''}")
     		}
     	}
    @@ -81,20 +99,30 @@ func RenderForLDApplicationTemplate(nodes []Node) string {
     
     // RenderForJSXText produces the body for embedding as bare JSX text (i.e.
     // not wrapped in a template literal). Only valid when the template has no
    -// interpolation; the caller is expected to consult HasInterpolation first.
    +// interpolation matching a declared input. Foreign-template `{{ name }}`
    +// passes through verbatim.
     //
     // Backslashes, backticks, and ${} sequences are emitted verbatim — JSX
     // text does not interpret any of those. Literal `{` and `}` characters
    -// are not handled here (they would require JSX-specific escaping); the
    -// caller should fall back to template-literal output if either appears.
    -func RenderForJSXText(nodes []Node) (string, error) {
    +// are not handled here; the caller should fall back to template-literal
    +// output if either appears.
    +func RenderForJSXText(nodes []Node, declaredInputs map[string]struct{}) (string, error) {
     	var sb strings.Builder
     	for _, n := range nodes {
    -		l, ok := n.(*Literal)
    -		if !ok {
    -			return "", fmt.Errorf("RenderForJSXText: template has interpolation; use RenderForLDApplicationTemplate")
    +		switch x := n.(type) {
    +		case *Literal:
    +			sb.WriteString(x.Text)
    +		case *Var:
    +			if _, ok := declaredInputs[x.Name]; ok {
    +				return "", fmt.Errorf("RenderForJSXText: template has declared interpolation; use RenderForLDApplicationTemplate")
    +			}
    +			// Foreign template — emit literal.
    +			sb.WriteString("{{ ")
    +			sb.WriteString(x.Name)
    +			sb.WriteString(" }}")
    +		case *Cond:
    +			return "", fmt.Errorf("RenderForJSXText: template has conditional; use RenderForLDApplicationTemplate")
     		}
    -		sb.WriteString(l.Text)
     	}
     	return sb.String(), nil
     }
    diff --git a/snippets/internal/render/template_test.go b/snippets/internal/render/template_test.go
    index b860ab0..6790bae 100644
    --- a/snippets/internal/render/template_test.go
    +++ b/snippets/internal/render/template_test.go
    @@ -5,6 +5,16 @@ import (
     	"testing"
     )
     
    +// allInputs returns a declaredInputs set containing the given names; helper
    +// for tests that don't care about foreign-template pass-through.
    +func allInputs(names ...string) map[string]struct{} {
    +	m := make(map[string]struct{}, len(names))
    +	for _, n := range names {
    +		m[n] = struct{}{}
    +	}
    +	return m
    +}
    +
     func TestParseAndRender(t *testing.T) {
     	nodes, err := Parse(`echo "launchdarkly-server-sdk{{ if version }}=={{ version }}{{ end }}" done`)
     	if err != nil {
    @@ -32,7 +42,7 @@ func TestParseAndRender(t *testing.T) {
     	}
     
     	// ld-application rendering produces a JS ternary expression
    -	got = RenderForLDApplicationTemplate(nodes)
    +	got = RenderForLDApplicationTemplate(nodes, allInputs("version"))
     	want = "echo \"launchdarkly-server-sdk${version ? `==${version}` : ''}\" done"
     	if got != want {
     		t.Fatalf("ld-application: got %q want %q", got, want)
    @@ -41,16 +51,15 @@ func TestParseAndRender(t *testing.T) {
     
     func TestRenderForLDApplicationTemplateEscapes(t *testing.T) {
     	nodes, _ := Parse("a \\ b ` c ${d} {{ name }}")
    -	got := RenderForLDApplicationTemplate(nodes)
    +	got := RenderForLDApplicationTemplate(nodes, allInputs("name"))
     	want := "a \\\\ b \\` c \\${d} ${name}"
     	if got != want {
     		t.Fatalf("escapes: got %q want %q", got, want)
     	}
     }
     
    -// Regression for review #4: a variable starting with `end` previously hit a
    -// HasPrefix check and was treated as a block-close. Now the case requires
    -// equality so `endTime` is recognized as a normal variable.
    +// Regression: a variable starting with `end` previously hit a HasPrefix check
    +// and was treated as a block-close. Now equality is required.
     func TestEndPrefixedVariable(t *testing.T) {
     	nodes, err := Parse(`{{ endTime }}`)
     	if err != nil {
    @@ -79,11 +88,27 @@ func TestUnclosedIf(t *testing.T) {
     	}
     }
     
    -func TestRenderRuntimeUnknownVar(t *testing.T) {
    -	nodes, _ := Parse(`{{ missing }}`)
    +// Foreign-template pass-through: an undeclared name in `{{ name }}` is
    +// emitted verbatim by RenderRuntime so a Vue snippet's `{{ flagValue }}`
    +// survives validation untouched.
    +func TestRenderRuntimePassesThroughUnknownVar(t *testing.T) {
    +	nodes, _ := Parse(`hello {{ flagValue }}`)
    +	got, err := RenderRuntime(nodes, map[string]string{})
    +	if err != nil {
    +		t.Fatalf("render: %v", err)
    +	}
    +	if got != `hello {{ flagValue }}` {
    +		t.Fatalf("got %q", got)
    +	}
    +}
    +
    +// A conditional referring to an undeclared input is still an authoring bug,
    +// not a foreign template — Vue uses `v-if`, not `{{ if … }}`.
    +func TestRenderRuntimeRejectsUnknownCondVar(t *testing.T) {
    +	nodes, _ := Parse(`{{ if missing }}body{{ end }}`)
     	_, err := RenderRuntime(nodes, map[string]string{})
    -	if err == nil || !strings.Contains(err.Error(), "missing runtime input") {
    -		t.Fatalf("want missing-input error, got %v", err)
    +	if err == nil || !strings.Contains(err.Error(), "undeclared input") {
    +		t.Fatalf("want undeclared-conditional error, got %v", err)
     	}
     }
     
    @@ -101,12 +126,11 @@ func TestRenderRuntimeEmptyTemplate(t *testing.T) {
     	}
     }
     
    -// Regression for review #10: the bare-JSX-text path must not apply
    -// template-literal escaping. A backslash in the source should round-trip
    -// verbatim.
    +// Bare-JSX-text path must not apply template-literal escaping. A backslash
    +// in the source should round-trip verbatim.
     func TestRenderForJSXTextNoEscape(t *testing.T) {
     	nodes, _ := Parse(`python .\main.py`)
    -	got, err := RenderForJSXText(nodes)
    +	got, err := RenderForJSXText(nodes, allInputs())
     	if err != nil {
     		t.Fatalf("render: %v", err)
     	}
    @@ -115,23 +139,42 @@ func TestRenderForJSXTextNoEscape(t *testing.T) {
     	}
     }
     
    -func TestRenderForJSXTextRefusesInterp(t *testing.T) {
    +// Declared interpolation in JSX-text rendering should fail loudly so the
    +// caller routes to the template-literal path.
    +func TestRenderForJSXTextRefusesDeclaredInterp(t *testing.T) {
     	nodes, _ := Parse(`hello {{ name }}`)
    -	if _, err := RenderForJSXText(nodes); err == nil {
    -		t.Fatalf("want error when template has interpolation")
    +	if _, err := RenderForJSXText(nodes, allInputs("name")); err == nil {
    +		t.Fatalf("want error when template has declared interpolation")
    +	}
    +}
    +
    +// Foreign-template `{{ name }}` in JSX text is fine — passes through.
    +func TestRenderForJSXTextPassesForeignTemplate(t *testing.T) {
    +	nodes, _ := Parse(`Feature flag {{ flagValue }} reads as expected`)
    +	got, err := RenderForJSXText(nodes, allInputs())
    +	if err != nil {
    +		t.Fatalf("render: %v", err)
    +	}
    +	if got != `Feature flag {{ flagValue }} reads as expected` {
    +		t.Fatalf("got %q", got)
     	}
     }
     
     func TestHasInterpolation(t *testing.T) {
    -	cases := map[string]bool{
    -		"plain":              false,
    -		"hello {{ name }}":   true,
    -		"{{ if a }}b{{ end }}": true,
    -	}
    -	for src, want := range cases {
    -		nodes, _ := Parse(src)
    -		if got := HasInterpolation(nodes); got != want {
    -			t.Errorf("HasInterpolation(%q) = %v, want %v", src, got, want)
    +	cases := []struct {
    +		src      string
    +		declared []string
    +		want     bool
    +	}{
    +		{"plain", nil, false},
    +		{"hello {{ name }}", []string{"name"}, true},
    +		{"hello {{ name }}", nil, false}, // foreign template, not interpolation
    +		{"{{ if a }}b{{ end }}", []string{"a"}, true},
    +	}
    +	for _, c := range cases {
    +		nodes, _ := Parse(c.src)
    +		if got := HasInterpolation(nodes, allInputs(c.declared...)); got != c.want {
    +			t.Errorf("HasInterpolation(%q, declared=%v) = %v, want %v", c.src, c.declared, got, c.want)
     		}
     	}
     }
    diff --git a/snippets/internal/validate/runner.go b/snippets/internal/validate/runner.go
    new file mode 100644
    index 0000000..cb9cfd4
    --- /dev/null
    +++ b/snippets/internal/validate/runner.go
    @@ -0,0 +1,53 @@
    +package validate
    +
    +import (
    +	"bytes"
    +	"fmt"
    +	"os"
    +	"path/filepath"
    +
    +	"gopkg.in/yaml.v3"
    +)
    +
    +// Runner is the parsed contents of a validator's runner.yaml. Each
    +// validators/languages// directory carries one.
    +type Runner struct {
    +	// Mode controls how the harness runs:
    +	//   "docker" — the Go validator builds the Dockerfile and runs
    +	//              harness/run.sh inside the resulting container with the
    +	//              staged snippet bind-mounted at /snippet.
    +	//   "native" — the Go validator execs harness/run.sh on the host with
    +	//              the staged snippet path passed as $SNIPPET_DIR.
    +	Mode string `yaml:"mode"`
    +
    +	// RunsOn is a hint for the CI workflow's matrix.runs-on. Not read by
    +	// the Go validator; included so the workflow file and the runner
    +	// descriptor stay in sync visually.
    +	RunsOn string `yaml:"runs-on"`
    +
    +	// ImagePrefix is the Docker image-tag prefix used for `mode: docker`.
    +	// The full tag is `:`.
    +	ImagePrefix string `yaml:"image-prefix"`
    +}
    +
    +func loadRunner(validatorsDir, runtime string) (*Runner, string, error) {
    +	dir := filepath.Join(validatorsDir, "languages", runtime)
    +	rp := filepath.Join(dir, "runner.yaml")
    +	raw, err := os.ReadFile(rp)
    +	if err != nil {
    +		return nil, "", fmt.Errorf("validator runner.yaml not found for runtime %q at %s: %w", runtime, rp, err)
    +	}
    +	var r Runner
    +	dec := yaml.NewDecoder(bytes.NewReader(raw))
    +	dec.KnownFields(true)
    +	if err := dec.Decode(&r); err != nil {
    +		return nil, "", fmt.Errorf("%s: %w", rp, err)
    +	}
    +	if r.Mode != "docker" && r.Mode != "native" {
    +		return nil, "", fmt.Errorf("%s: mode must be `docker` or `native`, got %q", rp, r.Mode)
    +	}
    +	if r.Mode == "docker" && r.ImagePrefix == "" {
    +		return nil, "", fmt.Errorf("%s: mode=docker requires image-prefix", rp)
    +	}
    +	return &r, dir, nil
    +}
    diff --git a/snippets/internal/validate/validate.go b/snippets/internal/validate/validate.go
    index 8701517..599f4a2 100644
    --- a/snippets/internal/validate/validate.go
    +++ b/snippets/internal/validate/validate.go
    @@ -21,22 +21,33 @@ type Config struct {
     	SDK           string // sdk id to validate (empty = all)
     }
     
    -// Run finds validatable snippets under cfg.SDKsDir and runs each through
    -// the per-language Docker validator. First-pass implementation: python only.
    -//
    -// Snippets are run against a real LaunchDarkly environment. Required env vars,
    -// matching the convention used by the hello-* sample apps:
    -//
    -//	LAUNCHDARKLY_SDK_KEY    server-side SDK key for the test environment
    -//	LAUNCHDARKLY_FLAG_KEY   the flag key the snippet should evaluate
    +// envInputs holds the environment-derived input values that get substituted
    +// into snippets at validation time. Each field maps to one EXAM-HELLO env
    +// var and to a snippet-input type.
    +type envInputs struct {
    +	sdkKey        string // LAUNCHDARKLY_SDK_KEY        ↔ type: sdk-key
    +	flagKey       string // LAUNCHDARKLY_FLAG_KEY       ↔ type: flag-key
    +	mobileKey     string // LAUNCHDARKLY_MOBILE_KEY     ↔ type: mobile-key
    +	clientSideID  string // LAUNCHDARKLY_CLIENT_SIDE_ID ↔ type: client-side-id
    +}
    +
    +// Run finds validatable snippets under cfg.SDKsDir and routes each through
    +// its language harness. A snippet is considered validatable when its
    +// frontmatter declares any one of:
    +//   - validation.runtime
    +//   - validation.entrypoint  (back-compat with the python first slice)
     //
    -// These are read from the caller's environment and forwarded into the
    -// per-snippet Docker run. They are never written to a file in the repo.
    +// EXAM-HELLO env vars (LAUNCHDARKLY_SDK_KEY, LAUNCHDARKLY_FLAG_KEY,
    +// LAUNCHDARKLY_MOBILE_KEY, LAUNCHDARKLY_CLIENT_SIDE_ID) are read from the
    +// caller's environment. Snippets that need a particular key declare an input
    +// of the matching type; the dispatcher refuses to run if a needed key is
    +// not set.
     func Run(cfg Config) error {
    -	sdkKey := os.Getenv("LAUNCHDARKLY_SDK_KEY")
    -	flagKey := os.Getenv("LAUNCHDARKLY_FLAG_KEY")
    -	if sdkKey == "" || flagKey == "" {
    -		return fmt.Errorf("LAUNCHDARKLY_SDK_KEY and LAUNCHDARKLY_FLAG_KEY must be set in the caller environment")
    +	env := envInputs{
    +		sdkKey:       os.Getenv("LAUNCHDARKLY_SDK_KEY"),
    +		flagKey:      os.Getenv("LAUNCHDARKLY_FLAG_KEY"),
    +		mobileKey:    os.Getenv("LAUNCHDARKLY_MOBILE_KEY"),
    +		clientSideID: os.Getenv("LAUNCHDARKLY_CLIENT_SIDE_ID"),
     	}
     
     	snippets, err := model.LoadAll(cfg.SDKsDir)
    @@ -50,11 +61,11 @@ func Run(cfg Config) error {
     		if cfg.SDK != "" && s.Frontmatter.SDK != cfg.SDK {
     			continue
     		}
    -		if s.Frontmatter.Validation.Entrypoint == "" {
    +		if !isValidatable(s) {
     			continue
     		}
     		any = true
    -		if err := runOne(cfg, s, sdkKey, flagKey); err != nil {
    +		if err := runOne(cfg, s, snippets, env); err != nil {
     			return fmt.Errorf("validate %s: %w", id, err)
     		}
     	}
    @@ -64,81 +75,175 @@ func Run(cfg Config) error {
     	return nil
     }
     
    -func runOne(cfg Config, s *model.Snippet, sdkKey, flagKey string) error {
    -	switch s.CodeLang {
    -	case "python":
    -		return runPython(cfg, s, sdkKey, flagKey)
    -	default:
    -		return fmt.Errorf("no validator for lang=%q", s.CodeLang)
    -	}
    +func isValidatable(s *model.Snippet) bool {
    +	return s.Frontmatter.Validation.Runtime != "" || s.Frontmatter.Validation.Entrypoint != ""
     }
     
    -func runPython(cfg Config, s *model.Snippet, sdkKey, flagKey string) error {
    -	if err := checkEntrypoint(s.Frontmatter.Validation.Entrypoint); err != nil {
    -		return err
    +func runOne(cfg Config, s *model.Snippet, all map[string]*model.Snippet, env envInputs) error {
    +	runtime := s.Frontmatter.Validation.Runtime
    +	if runtime == "" {
    +		runtime = s.CodeLang
     	}
    -	if err := checkRequirements(s.Frontmatter.Validation.Requirements); err != nil {
    -		return err
    +	if runtime == "" {
    +		return fmt.Errorf("snippet %q: cannot determine validator runtime (set validation.runtime or lang)", s.Frontmatter.ID)
     	}
     
    -	inputs, err := runtimeInputs(s, sdkKey, flagKey)
    +	runner, runnerDir, err := loadRunner(cfg.ValidatorsDir, runtime)
     	if err != nil {
     		return err
     	}
    -	nodes, err := render.Parse(s.CodeBody)
    -	if err != nil {
    +
    +	if err := requireEnvForInputs(s, all, env); err != nil {
     		return err
     	}
    -	code, err := render.RenderRuntime(nodes, inputs)
    +
    +	stageDir, err := stageSnippet(s, all, env)
     	if err != nil {
     		return err
     	}
    +	defer os.RemoveAll(stageDir)
    +
    +	entrypoint := entrypointPath(s)
    +	fmt.Printf("--- validate %s (runtime=%s, entrypoint=%s) ---\n", s.Frontmatter.ID, runtime, entrypoint)
     
    +	switch runner.Mode {
    +	case "docker":
    +		return runDocker(cfg, runner, runnerDir, stageDir, entrypoint, env)
    +	case "native":
    +		return runNative(runnerDir, stageDir, entrypoint, env)
    +	default:
    +		return fmt.Errorf("validator runtime %q: unknown mode %q", runtime, runner.Mode)
    +	}
    +}
    +
    +// entrypointPath returns the relative path the harness should invoke. If the
    +// snippet declares validation.entrypoint use that; otherwise fall back to
    +// the file: field (which is also where the body is staged).
    +func entrypointPath(s *model.Snippet) string {
    +	if s.Frontmatter.Validation.Entrypoint != "" {
    +		return s.Frontmatter.Validation.Entrypoint
    +	}
    +	return s.Frontmatter.File
    +}
    +
    +// stageSnippet writes the snippet body and any companion bodies into a
    +// temp directory shaped exactly like the project the harness expects.
    +//
    +// Each snippet (entrypoint + companions) is rendered with runtime inputs and
    +// written at its `file:` path under stageDir. A snippet without a `file:`
    +// field is an error — we need to know where to put its body.
    +func stageSnippet(entry *model.Snippet, all map[string]*model.Snippet, env envInputs) (string, error) {
     	stageDir, err := os.MkdirTemp("", "snippets-validate-")
     	if err != nil {
    -		return err
    +		return "", err
     	}
    -	defer os.RemoveAll(stageDir)
     
    -	entrypoint := s.Frontmatter.Validation.Entrypoint
    -	if err := os.WriteFile(filepath.Join(stageDir, entrypoint), []byte(code), 0o644); err != nil {
    -		return err
    +	if err := stageOne(stageDir, entry, env); err != nil {
    +		os.RemoveAll(stageDir)
    +		return "", err
     	}
    -	if s.Frontmatter.Validation.Requirements != "" {
    +	for _, cid := range entry.Frontmatter.Validation.Companions {
    +		comp, ok := all[cid]
    +		if !ok {
    +			os.RemoveAll(stageDir)
    +			return "", fmt.Errorf("snippet %s: companion %q not found", entry.Frontmatter.ID, cid)
    +		}
    +		if err := stageOne(stageDir, comp, env); err != nil {
    +			os.RemoveAll(stageDir)
    +			return "", err
    +		}
    +	}
    +
    +	// Python convention: validation.requirements becomes requirements.txt.
    +	// Other runtimes carry their dependency manifest as a companion snippet
    +	// (pom.xml, Cargo.toml, etc.).
    +	if req := entry.Frontmatter.Validation.Requirements; req != "" {
    +		if err := checkRequirements(req); err != nil {
    +			os.RemoveAll(stageDir)
    +			return "", err
    +		}
     		if err := os.WriteFile(filepath.Join(stageDir, "requirements.txt"),
    -			[]byte(s.Frontmatter.Validation.Requirements+"\n"), 0o644); err != nil {
    -			return err
    +			[]byte(req+"\n"), 0o644); err != nil {
    +			os.RemoveAll(stageDir)
    +			return "", err
     		}
     	}
    +	return stageDir, nil
    +}
    +
    +func stageOne(stageDir string, s *model.Snippet, env envInputs) error {
    +	rel := s.Frontmatter.File
    +	if rel == "" {
    +		return fmt.Errorf("snippet %s: frontmatter.file is required for staging", s.Frontmatter.ID)
    +	}
    +	if err := checkStagePath(rel); err != nil {
    +		return fmt.Errorf("snippet %s: %w", s.Frontmatter.ID, err)
    +	}
    +	inputs, err := runtimeInputs(s, env)
    +	if err != nil {
    +		return fmt.Errorf("snippet %s: %w", s.Frontmatter.ID, err)
    +	}
    +	nodes, err := render.Parse(s.CodeBody)
    +	if err != nil {
    +		return fmt.Errorf("snippet %s: %w", s.Frontmatter.ID, err)
    +	}
    +	body, err := render.RenderRuntime(nodes, inputs)
    +	if err != nil {
    +		return fmt.Errorf("snippet %s: %w", s.Frontmatter.ID, err)
    +	}
    +	dst := filepath.Join(stageDir, rel)
    +	if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
    +		return err
    +	}
    +	return os.WriteFile(dst, []byte(body), 0o644)
    +}
     
    -	validatorDir := filepath.Join(cfg.ValidatorsDir, "languages", "python")
    -	if _, err := os.Stat(filepath.Join(validatorDir, "Dockerfile")); err != nil {
    -		return fmt.Errorf("validator Dockerfile not found at %s: %w", validatorDir, err)
    +// checkStagePath rejects file paths that escape the staging directory.
    +func checkStagePath(rel string) error {
    +	clean := filepath.Clean(rel)
    +	if filepath.IsAbs(clean) {
    +		return fmt.Errorf("frontmatter.file %q must be relative", rel)
    +	}
    +	if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
    +		return fmt.Errorf("frontmatter.file %q escapes staging directory", rel)
     	}
    +	return nil
    +}
     
    -	// Tag the image by hash of its build context. Two concurrent validate runs
    -	// on the same Docker host won't race on a shared mutable tag, and rebuilds
    -	// are skipped automatically when the validator hasn't changed.
    -	tag, err := validatorImageTag(validatorDir)
    +// runDocker builds the validator's Dockerfile and runs harness/run.sh inside
    +// the resulting container with the staged snippet bind-mounted at /snippet.
    +//
    +// Build context is the entire `validators/` tree so each Dockerfile can pull
    +// from `shared/` (the shared harness library) as well as its own
    +// `languages//` subtree.
    +func runDocker(cfg Config, runner *Runner, runnerDir, stageDir, entrypoint string, env envInputs) error {
    +	dockerfile := filepath.Join(runnerDir, "Dockerfile")
    +	if _, err := os.Stat(dockerfile); err != nil {
    +		return fmt.Errorf("validator Dockerfile not found at %s: %w", runnerDir, err)
    +	}
    +	tag, err := validatorImageTag(cfg.ValidatorsDir, runnerDir, runner.ImagePrefix)
     	if err != nil {
     		return err
     	}
    -	build := exec.Command("docker", "build", "--quiet", "-t", tag, validatorDir)
    +	build := exec.Command("docker", "build", "--quiet",
    +		"-f", dockerfile,
    +		"-t", tag,
    +		cfg.ValidatorsDir,
    +	)
     	build.Stdout = os.Stdout
     	build.Stderr = os.Stderr
     	if err := build.Run(); err != nil {
     		return fmt.Errorf("docker build failed: %w", err)
     	}
    -
    -	fmt.Printf("--- validate %s (lang=%s, entrypoint=%s) ---\n", s.Frontmatter.ID, s.CodeLang, entrypoint)
    -
    -	run := exec.Command("docker", "run", "--rm",
    -		"-v", stageDir+":/snippet:ro",
    -		"-e", "SNIPPET_ENTRYPOINT="+entrypoint,
    -		"-e", "LAUNCHDARKLY_SDK_KEY="+sdkKey,
    -		"-e", "LAUNCHDARKLY_FLAG_KEY="+flagKey,
    -		tag,
    -	)
    +	args := []string{"run", "--rm",
    +		"-v", stageDir + ":/snippet:ro",
    +		"-e", "SNIPPET_ENTRYPOINT=" + entrypoint,
    +	}
    +	for _, kv := range envForRun(env) {
    +		args = append(args, "-e", kv)
    +	}
    +	args = append(args, tag)
    +	run := exec.Command("docker", args...)
     	run.Stdout = os.Stdout
     	run.Stderr = os.Stderr
     	if err := run.Run(); err != nil {
    @@ -147,23 +252,122 @@ func runPython(cfg Config, s *model.Snippet, sdkKey, flagKey string) error {
     	return nil
     }
     
    -// checkEntrypoint rejects any value that isn't a plain filename. Snippet
    -// frontmatter is author-controlled, so without this guard a malicious
    -// `entrypoint: ../../../etc/foo` would let `os.WriteFile(stageDir+entrypoint)`
    -// land outside the staging directory.
    -func checkEntrypoint(entrypoint string) error {
    -	if entrypoint == "" {
    +// runNative execs the harness's run.sh on the host with the staged snippet
    +// path passed as $SNIPPET_DIR. Used for runtimes whose toolchains can't run
    +// in a Linux container (iOS / xcodebuild) or are too heavy to dockerize for
    +// CI (Android emulator, Flutter).
    +func runNative(runnerDir, stageDir, entrypoint string, env envInputs) error {
    +	script := filepath.Join(runnerDir, "harness", "run.sh")
    +	if _, err := os.Stat(script); err != nil {
    +		return fmt.Errorf("native validator run.sh not found at %s: %w", script, err)
    +	}
    +	cmd := exec.Command("/bin/sh", script)
    +	cmd.Stdout = os.Stdout
    +	cmd.Stderr = os.Stderr
    +	cmd.Env = append(os.Environ(),
    +		"SNIPPET_DIR="+stageDir,
    +		"SNIPPET_ENTRYPOINT="+entrypoint,
    +	)
    +	cmd.Env = append(cmd.Env, envForRun(env)...)
    +	if err := cmd.Run(); err != nil {
    +		return fmt.Errorf("native validator failed: %w", err)
    +	}
    +	return nil
    +}
    +
    +// envForRun returns the EXAM-HELLO env-var KEY=VALUE pairs that should be
    +// forwarded into the harness. Empty values are still forwarded (the harness
    +// can decide whether to require them); the per-snippet
    +// requireEnvForInputs check has already failed fast on missing values that
    +// the snippet actually needs.
    +func envForRun(env envInputs) []string {
    +	return []string{
    +		"LAUNCHDARKLY_SDK_KEY=" + env.sdkKey,
    +		"LAUNCHDARKLY_FLAG_KEY=" + env.flagKey,
    +		"LAUNCHDARKLY_MOBILE_KEY=" + env.mobileKey,
    +		"LAUNCHDARKLY_CLIENT_SIDE_ID=" + env.clientSideID,
    +	}
    +}
    +
    +// requireEnvForInputs walks the entrypoint snippet AND its companions; for
    +// every input typed as one of the EXAM-HELLO key types, the corresponding
    +// env var must be set. This produces a clear error before a downstream pip-
    +// install or docker-build has wasted time.
    +func requireEnvForInputs(entry *model.Snippet, all map[string]*model.Snippet, env envInputs) error {
    +	check := func(s *model.Snippet) error {
    +		for name, in := range s.Frontmatter.Inputs {
    +			switch in.Type {
    +			case "flag-key":
    +				if env.flagKey == "" {
    +					return fmt.Errorf("snippet %s input %q (type=flag-key) requires LAUNCHDARKLY_FLAG_KEY to be set", s.Frontmatter.ID, name)
    +				}
    +			case "sdk-key":
    +				if env.sdkKey == "" {
    +					return fmt.Errorf("snippet %s input %q (type=sdk-key) requires LAUNCHDARKLY_SDK_KEY to be set", s.Frontmatter.ID, name)
    +				}
    +			case "mobile-key":
    +				if env.mobileKey == "" {
    +					return fmt.Errorf("snippet %s input %q (type=mobile-key) requires LAUNCHDARKLY_MOBILE_KEY to be set", s.Frontmatter.ID, name)
    +				}
    +			case "client-side-id":
    +				if env.clientSideID == "" {
    +					return fmt.Errorf("snippet %s input %q (type=client-side-id) requires LAUNCHDARKLY_CLIENT_SIDE_ID to be set", s.Frontmatter.ID, name)
    +				}
    +			}
    +		}
     		return nil
     	}
    -	if entrypoint != filepath.Base(entrypoint) {
    -		return fmt.Errorf("validation.entrypoint %q must be a plain filename (no path separators or ..)", entrypoint)
    +	if err := check(entry); err != nil {
    +		return err
     	}
    -	if entrypoint == "." || entrypoint == ".." {
    -		return fmt.Errorf("validation.entrypoint %q is not a valid filename", entrypoint)
    +	for _, cid := range entry.Frontmatter.Validation.Companions {
    +		if comp, ok := all[cid]; ok {
    +			if err := check(comp); err != nil {
    +				return err
    +			}
    +		}
     	}
     	return nil
     }
     
    +// runtimeInputs derives concrete values for every declared input.
    +//
    +// Inputs typed flag-key / sdk-key / mobile-key / client-side-id pull from
    +// the EXAM-HELLO env vars carried in `env`. Other inputs fall back to the
    +// snippet's own runtime-default. Declaring runtime-default for any of the
    +// EXAM-HELLO key types is an error: those values must always come from the
    +// caller's environment so real keys never end up committed.
    +func runtimeInputs(s *model.Snippet, env envInputs) (map[string]string, error) {
    +	out := map[string]string{}
    +	for name, in := range s.Frontmatter.Inputs {
    +		switch in.Type {
    +		case "flag-key":
    +			if in.RuntimeDefault != "" {
    +				return nil, fmt.Errorf("input %q (type=flag-key) must not declare runtime-default", name)
    +			}
    +			out[name] = env.flagKey
    +		case "sdk-key":
    +			if in.RuntimeDefault != "" {
    +				return nil, fmt.Errorf("input %q (type=sdk-key) must not declare runtime-default", name)
    +			}
    +			out[name] = env.sdkKey
    +		case "mobile-key":
    +			if in.RuntimeDefault != "" {
    +				return nil, fmt.Errorf("input %q (type=mobile-key) must not declare runtime-default", name)
    +			}
    +			out[name] = env.mobileKey
    +		case "client-side-id":
    +			if in.RuntimeDefault != "" {
    +				return nil, fmt.Errorf("input %q (type=client-side-id) must not declare runtime-default", name)
    +			}
    +			out[name] = env.clientSideID
    +		default:
    +			out[name] = in.RuntimeDefault
    +		}
    +	}
    +	return out, nil
    +}
    +
     // checkRequirements rejects values that would let a snippet author smuggle
     // pip flags through the requirements.txt that the validator writes. The
     // allow-list is "one or more requirement specifiers, separated by single
    @@ -185,60 +389,40 @@ func checkRequirements(req string) error {
     	return nil
     }
     
    -// validatorImageTag produces a Docker tag that's a content hash of the
    -// validator directory: a deterministic tag that changes only when a file in
    -// the validator changes. Concurrent validate runs against the same validator
    -// thus reuse the cached image; runs against different validators get different
    -// tags so they cannot interleave.
    -func validatorImageTag(dir string) (string, error) {
    +// validatorImageTag produces a Docker tag that's a content hash of both the
    +// shared harness library AND the per-language validator directory. A change
    +// in either place forces a rebuild; concurrent validate runs against the
    +// same validator share the cached image.
    +func validatorImageTag(validatorsDir, runnerDir, prefix string) (string, error) {
     	h := sha256.New()
    -	err := filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error {
    -		if err != nil {
    -			return err
    -		}
    -		if d.IsDir() {
    -			return nil
    +	for _, sub := range []string{"shared", ""} {
    +		// "" means "the runner dir itself"; otherwise sub is rooted at validatorsDir.
    +		var root string
    +		if sub == "" {
    +			root = runnerDir
    +		} else {
    +			root = filepath.Join(validatorsDir, sub)
     		}
    -		f, err := os.Open(p)
    -		if err != nil {
    -			return err
    -		}
    -		defer f.Close()
    -		rel, _ := filepath.Rel(dir, p)
    -		fmt.Fprintf(h, "%s\x00", rel)
    -		_, err = io.Copy(h, f)
    -		return err
    -	})
    -	if err != nil {
    -		return "", err
    -	}
    -	return "sdk-snippets/python-validator:" + hex.EncodeToString(h.Sum(nil))[:16], nil
    -}
    -
    -// runtimeInputs derives concrete values for every declared input.
    -//
    -// Inputs typed as `flag-key` use LAUNCHDARKLY_FLAG_KEY; inputs typed as
    -// `sdk-key` use LAUNCHDARKLY_SDK_KEY. Both come from the caller's env so the
    -// snippet's rendered output never embeds a real key. Other inputs fall back
    -// to the snippet's own runtime-default. Declaring runtime-default for either
    -// keyed type is an error: the value must always come from the environment.
    -func runtimeInputs(s *model.Snippet, sdkKey, flagKey string) (map[string]string, error) {
    -	out := map[string]string{}
    -	for name, in := range s.Frontmatter.Inputs {
    -		switch in.Type {
    -		case "flag-key":
    -			if in.RuntimeDefault != "" {
    -				return nil, fmt.Errorf("input %q (type=flag-key) must not declare runtime-default — value comes from LAUNCHDARKLY_FLAG_KEY", name)
    +		err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
    +			if err != nil {
    +				return err
     			}
    -			out[name] = flagKey
    -		case "sdk-key":
    -			if in.RuntimeDefault != "" {
    -				return nil, fmt.Errorf("input %q (type=sdk-key) must not declare runtime-default — value comes from LAUNCHDARKLY_SDK_KEY", name)
    +			if d.IsDir() {
    +				return nil
     			}
    -			out[name] = sdkKey
    -		default:
    -			out[name] = in.RuntimeDefault
    +			f, err := os.Open(p)
    +			if err != nil {
    +				return err
    +			}
    +			defer f.Close()
    +			rel, _ := filepath.Rel(validatorsDir, p)
    +			fmt.Fprintf(h, "%s\x00", rel)
    +			_, err = io.Copy(h, f)
    +			return err
    +		})
    +		if err != nil {
    +			return "", err
     		}
     	}
    -	return out, nil
    +	return prefix + ":" + hex.EncodeToString(h.Sum(nil))[:16], nil
     }
    diff --git a/snippets/internal/validate/validate_test.go b/snippets/internal/validate/validate_test.go
    index ca3ad58..fc8dace 100644
    --- a/snippets/internal/validate/validate_test.go
    +++ b/snippets/internal/validate/validate_test.go
    @@ -7,32 +7,32 @@ import (
     	"github.com/launchdarkly/sdk-meta/snippets/internal/model"
     )
     
    -// Regression for review #2: snippet author-controlled values may not escape
    -// the staging directory.
    -func TestCheckEntrypointRejectsTraversal(t *testing.T) {
    +// Regression for review #2: snippet `file:` paths may not escape the staging
    +// directory. The check moved from a dedicated entrypoint guard to a stage-
    +// path guard during the multi-file refactor.
    +func TestCheckStagePathRejectsTraversal(t *testing.T) {
     	bad := []string{
     		"../etc/passwd",
     		"../../home/x/.ssh/authorized_keys",
    -		"a/b/c.py",
    -		"./main.py",
    -		".",
    +		"/etc/passwd",
     		"..",
     	}
     	for _, e := range bad {
    -		if err := checkEntrypoint(e); err == nil {
    -			t.Errorf("checkEntrypoint(%q): expected error", e)
    +		if err := checkStagePath(e); err == nil {
    +			t.Errorf("checkStagePath(%q): expected error", e)
     		}
     	}
    -	good := []string{"main.py", "app.py", "snippet_test.py"}
    +	good := []string{
    +		"main.py",
    +		"src/main.rs",
    +		"src/main/java/com/launchdarkly/HelloLD.java",
    +		"./main.py", // filepath.Clean normalizes to main.py
    +	}
     	for _, e := range good {
    -		if err := checkEntrypoint(e); err != nil {
    -			t.Errorf("checkEntrypoint(%q): unexpected error %v", e, err)
    +		if err := checkStagePath(e); err != nil {
    +			t.Errorf("checkStagePath(%q): unexpected error %v", e, err)
     		}
     	}
    -	// Empty is allowed (no validation entrypoint declared).
    -	if err := checkEntrypoint(""); err != nil {
    -		t.Errorf("empty entrypoint should be allowed: %v", err)
    -	}
     }
     
     // Regression for review #8: pip flag injection via requirements.txt.
    @@ -61,35 +61,45 @@ func TestCheckRequirementsRejectsPipFlags(t *testing.T) {
     	}
     }
     
    -// Regression for review #7: sdk-key inputs must come from the env, not from
    -// a snippet's runtime-default.
    +// All four EXAM-HELLO key types pull from env, not from runtime-default.
     func TestRuntimeInputs(t *testing.T) {
    +	env := envInputs{
    +		sdkKey:       "real-sdk-key",
    +		flagKey:      "real-flag-key",
    +		mobileKey:    "real-mobile-key",
    +		clientSideID: "real-client-side-id",
    +	}
     	s := &model.Snippet{
     		Frontmatter: model.Frontmatter{
     			Inputs: map[string]model.Input{
    -				"apiKey":     {Type: "sdk-key"},
    -				"featureKey": {Type: "flag-key"},
    -				"version":    {Type: "string", RuntimeDefault: "1.2.3"},
    +				"apiKey":         {Type: "sdk-key"},
    +				"featureKey":     {Type: "flag-key"},
    +				"mobileKey":      {Type: "mobile-key"},
    +				"clientSideId":   {Type: "client-side-id"},
    +				"version":        {Type: "string", RuntimeDefault: "1.2.3"},
     			},
     		},
     	}
    -	got, err := runtimeInputs(s, "real-sdk-key", "real-flag-key")
    +	got, err := runtimeInputs(s, env)
     	if err != nil {
     		t.Fatal(err)
     	}
    -	if got["apiKey"] != "real-sdk-key" {
    -		t.Errorf("apiKey: got %q want real-sdk-key", got["apiKey"])
    -	}
    -	if got["featureKey"] != "real-flag-key" {
    -		t.Errorf("featureKey: got %q want real-flag-key", got["featureKey"])
    +	want := map[string]string{
    +		"apiKey":       "real-sdk-key",
    +		"featureKey":   "real-flag-key",
    +		"mobileKey":    "real-mobile-key",
    +		"clientSideId": "real-client-side-id",
    +		"version":      "1.2.3",
     	}
    -	if got["version"] != "1.2.3" {
    -		t.Errorf("version: got %q want 1.2.3", got["version"])
    +	for k, v := range want {
    +		if got[k] != v {
    +			t.Errorf("input %q: got %q want %q", k, got[k], v)
    +		}
     	}
     }
     
     func TestRuntimeInputsRejectsRuntimeDefaultOnKeys(t *testing.T) {
    -	for _, kind := range []string{"sdk-key", "flag-key"} {
    +	for _, kind := range []string{"sdk-key", "flag-key", "mobile-key", "client-side-id"} {
     		s := &model.Snippet{
     			Frontmatter: model.Frontmatter{
     				Inputs: map[string]model.Input{
    @@ -97,7 +107,7 @@ func TestRuntimeInputsRejectsRuntimeDefaultOnKeys(t *testing.T) {
     				},
     			},
     		}
    -		_, err := runtimeInputs(s, "x", "y")
    +		_, err := runtimeInputs(s, envInputs{})
     		if err == nil || !strings.Contains(err.Error(), "runtime-default") {
     			t.Errorf("%s: want runtime-default rejection, got %v", kind, err)
     		}
    diff --git a/snippets/validators/languages/python/Dockerfile b/snippets/validators/languages/python/Dockerfile
    index d5fb464..760782e 100644
    --- a/snippets/validators/languages/python/Dockerfile
    +++ b/snippets/validators/languages/python/Dockerfile
    @@ -1,7 +1,11 @@
    +# Build context is `validators/`. The Dockerfile references
    +# `languages/python/...` for its own files and `shared/...` for the shared
    +# harness library that other validators also use.
     FROM python:3.11-slim
     
     WORKDIR /snippet
     
    -COPY harness /harness
    +COPY shared /harness-shared
    +COPY languages/python/harness /harness
     
     ENTRYPOINT ["/harness/run.sh"]
    diff --git a/snippets/validators/languages/python/harness/run.sh b/snippets/validators/languages/python/harness/run.sh
    index 0ad6fb0..f2abdf5 100755
    --- a/snippets/validators/languages/python/harness/run.sh
    +++ b/snippets/validators/languages/python/harness/run.sh
    @@ -1,21 +1,10 @@
     #!/bin/sh
    -# Validator entrypoint. Runs the staged snippet against a real LaunchDarkly
    -# environment.
    -#
    -# Required env (passed in by `snippets validate`):
    -#   LAUNCHDARKLY_SDK_KEY    server-side SDK key for the test environment
    -#   LAUNCHDARKLY_FLAG_KEY   the flag the snippet is templated to evaluate
    -#   SNIPPET_ENTRYPOINT      file under /snippet to run (e.g. main.py)
    -#
    -# Success criterion: the snippet prints
    -#   `*** The  feature flag evaluates to ...`
    -# within the timeout. That line only appears on a successful SDK init + flag
    -# evaluation, so matching it is a real signal.
    +# Runs the staged Python snippet against a real LaunchDarkly environment.
    +# Inputs (env): LAUNCHDARKLY_SDK_KEY, LAUNCHDARKLY_FLAG_KEY, SNIPPET_ENTRYPOINT.
     set -eu
     
    -: "${LAUNCHDARKLY_SDK_KEY:?LAUNCHDARKLY_SDK_KEY not set}"
    -: "${LAUNCHDARKLY_FLAG_KEY:?LAUNCHDARKLY_FLAG_KEY not set}"
    -: "${SNIPPET_ENTRYPOINT:?SNIPPET_ENTRYPOINT not set}"
    +. /harness-shared/lib.sh
    +require_env LAUNCHDARKLY_SDK_KEY LAUNCHDARKLY_FLAG_KEY SNIPPET_ENTRYPOINT
     
     if [ -f /snippet/requirements.txt ]; then
         pip install --quiet --no-input -r /snippet/requirements.txt
    @@ -23,38 +12,19 @@ fi
     
     LOG=$(mktemp)
     trap 'rm -f "$LOG"' EXIT
    -
     cd /snippet
     
    -# Hello-world programs block on Event().wait(); run with a timeout. We watch
    -# the log for the success line and SIGTERM the process as soon as it appears.
    +# Hello-world programs block on Event().wait(); run with a timeout. The
    +# shared lib watches the log for the success line and SIGTERMs the process
    +# as soon as it appears.
     PYTHONUNBUFFERED=1 timeout --signal=TERM 30s python "$SNIPPET_ENTRYPOINT" >"$LOG" 2>&1 &
     PID=$!
     
     deadline=$(( $(date +%s) + 25 ))
    -prefix="*** The ${LAUNCHDARKLY_FLAG_KEY} feature flag evaluates to "
    -
    -while [ "$(date +%s)" -lt "$deadline" ]; do
    -    if grep -F -- "$prefix" "$LOG" >/dev/null 2>&1; then
    -        kill -TERM "$PID" 2>/dev/null || true
    -        wait "$PID" 2>/dev/null || true
    -        # Echo the matched line so the caller sees the actual value.
    -        grep -F -- "$prefix" "$LOG" | head -1
    -        echo "validator: ok"
    -        exit 0
    -    fi
    -    if ! kill -0 "$PID" 2>/dev/null; then
    -        break
    -    fi
    -    sleep 0.2
    -done
    +if await_success_line "$LOG" "$PID" "$deadline"; then
    +    exit 0
    +fi
     
     kill -TERM "$PID" 2>/dev/null || true
     wait "$PID" 2>/dev/null || true
    -echo "validator: did not see expected line: ${prefix}" >&2
    -echo "--- snippet output (LAUNCHDARKLY_SDK_KEY redacted) ---" >&2
    -# Defense-in-depth: today's snippets never print LAUNCHDARKLY_SDK_KEY, but a
    -# future snippet could (intentionally or by accident) and this log gets piped
    -# into CI output. Redact any literal occurrence of the key before dumping.
    -sed -e "s|${LAUNCHDARKLY_SDK_KEY}||g" "$LOG" >&2
    -exit 1
    +fail_with_log "$LOG" "did not see expected line: feature flag evaluates to true"
    diff --git a/snippets/validators/languages/python/runner.yaml b/snippets/validators/languages/python/runner.yaml
    new file mode 100644
    index 0000000..87a94c4
    --- /dev/null
    +++ b/snippets/validators/languages/python/runner.yaml
    @@ -0,0 +1,13 @@
    +# Validator harness descriptor. Each validators/languages// has one.
    +#
    +# mode: docker | native
    +#   docker — the Go validator builds the Dockerfile in this dir and runs
    +#            harness/run.sh inside the container with /snippet bind-mounted
    +#   native — the Go validator execs harness/run.sh on the host with the
    +#            staged snippet path passed as $SNIPPET_DIR
    +#
    +# runs-on: hint for the CI workflow's matrix.runs-on field. Not read by the
    +#          Go validator.
    +mode: docker
    +runs-on: ubuntu-latest
    +image-prefix: sdk-snippets/python-validator
    diff --git a/snippets/validators/shared/lib.sh b/snippets/validators/shared/lib.sh
    new file mode 100644
    index 0000000..044290d
    --- /dev/null
    +++ b/snippets/validators/shared/lib.sh
    @@ -0,0 +1,83 @@
    +# Shared helpers for validator harnesses. Source from each language's
    +# harness/run.sh:
    +#
    +#   . /harness-shared/lib.sh
    +#
    +# Or, for native (non-docker) harnesses, the per-language run.sh references
    +# this via a relative path under validators/shared/lib.sh.
    +
    +# require_env ... exits 1 with a clear message if any var is unset.
    +require_env() {
    +    for name in "$@"; do
    +        eval "val=\${$name-}"
    +        if [ -z "$val" ]; then
    +            echo "$name not set" >&2
    +            exit 1
    +        fi
    +    done
    +}
    +
    +# await_success_line   
    +# Returns 0 once the log file contains the EXAM-HELLO success line, or 1
    +# when the deadline elapses (or the program exits before matching).
    +#
    +# The success regex is intentionally lenient on framing — different SDK
    +# hello-worlds quote the flag key differently ("sample-feature",
    +# 'sample-feature', sample-feature) and Python prints `True` while every
    +# other language prints `true`. The phrase `feature flag evaluates to
    +# [Tt]rue` is the one canonical fragment all of them emit on a successful
    +# init+evaluation against the EXAM-HELLO `sample-feature` flag.
    +await_success_line() {
    +    log=$1
    +    pid=$2
    +    deadline=$3
    +    while [ "$(date +%s)" -lt "$deadline" ]; do
    +        if grep -E "feature flag evaluates to [Tt]rue" "$log" >/dev/null 2>&1; then
    +            kill -TERM "$pid" 2>/dev/null || true
    +            wait "$pid" 2>/dev/null || true
    +            grep -E "feature flag evaluates to [Tt]rue" "$log" | head -1
    +            echo "validator: ok"
    +            return 0
    +        fi
    +        if ! kill -0 "$pid" 2>/dev/null; then
    +            break
    +        fi
    +        sleep 0.2
    +    done
    +    return 1
    +}
    +
    +# dump_redacted 
    +# Prints the log to stderr with LAUNCHDARKLY_SDK_KEY (and optionally
    +# LAUNCHDARKLY_MOBILE_KEY / LAUNCHDARKLY_CLIENT_SIDE_ID) replaced with a
    +# placeholder. Defense in depth — today's snippets never echo a key, but a
    +# future snippet could and this log gets piped into CI output.
    +dump_redacted() {
    +    log=$1
    +    # Build a sed program that redacts each defined key. Empty values are
    +    # skipped so we don't substitute the empty string.
    +    sed_args=""
    +    for var in LAUNCHDARKLY_SDK_KEY LAUNCHDARKLY_MOBILE_KEY LAUNCHDARKLY_CLIENT_SIDE_ID; do
    +        eval "val=\${$var-}"
    +        if [ -n "$val" ]; then
    +            sed_args="$sed_args -e s|$val||g"
    +        fi
    +    done
    +    if [ -n "$sed_args" ]; then
    +        # shellcheck disable=SC2086
    +        sed $sed_args "$log" >&2
    +    else
    +        cat "$log" >&2
    +    fi
    +}
    +
    +# fail_with_log  
    +# Convenience: print the failure message and the redacted log, then exit 1.
    +fail_with_log() {
    +    log=$1
    +    msg=$2
    +    echo "validator: $msg" >&2
    +    echo "--- snippet output (keys redacted) ---" >&2
    +    dump_redacted "$log"
    +    exit 1
    +}