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..015dbc4 --- /dev/null +++ b/snippets/docs/AUTHORING.md @@ -0,0 +1,87 @@ +# 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. + +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 marker hash covers only the *children* of the element (between `>`
+and `... 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 {
+		return false, err
+	}
+	src := string(raw)
+
+	matches, err := markers.ScanTSX(src)
+	if err != nil {
+		return false, err
+	}
+	if len(matches) == 0 {
+		return false, nil
+	}
+
+	// Build output by replacing each match's region in left-to-right order.
+	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)
+		}
+		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, declared) + trailing
+
+		// The hash covers ONLY the children we own. Attributes on the
+		// element are the consumer's to choose (lang="…", withCopyButton,
+		// className, etc.) and re-styling them must not require re-running
+		// `snippets render`. This matches the scope=content contract.
+		newHash := markers.HashContent(jsxBody)
+
+		if dryRun {
+			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.RegionStart:m.RegionEnd])
+			if m.Fields.Hash != actualHash {
+				return false, fmt.Errorf("marker %q: hand-edit detected — children 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:    newHash,
+			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, atomicWriteFile(path, []byte(sb.String()))
+}
+
+// atomicWriteFile writes to a same-directory tempfile, fsyncs, and renames
+// over the destination. The destination's permission bits are preserved so
+// running `snippets render` on a checkout that has tightened permissions
+// (e.g. read-only mode for a CODEOWNER-protected file) doesn't quietly
+// reset them to 0644.
+func atomicWriteFile(path string, data []byte) error {
+	dir := filepath.Dir(path)
+	mode := os.FileMode(0o644)
+	if info, err := os.Stat(path); err == nil {
+		mode = info.Mode().Perm()
+	}
+	// Random suffix avoids colliding with parallel renders.
+	var sfx [8]byte
+	if _, err := rand.Read(sfx[:]); err != nil {
+		return err
+	}
+	tmp := filepath.Join(dir, "."+filepath.Base(path)+".sdk-snippets."+hex.EncodeToString(sfx[:])+".tmp")
+	f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
+	if err != nil {
+		return err
+	}
+	if _, err := f.Write(data); err != nil {
+		f.Close()
+		os.Remove(tmp)
+		return err
+	}
+	if err := f.Sync(); err != nil {
+		f.Close()
+		os.Remove(tmp)
+		return err
+	}
+	if err := f.Close(); err != nil {
+		os.Remove(tmp)
+		return err
+	}
+	if err := os.Rename(tmp, path); err != nil {
+		os.Remove(tmp)
+		return err
+	}
+	return nil
+}
+
+// renderForJSXChild produces the bytes that go between  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, declared map[string]struct{}) string {
+	if needsTemplateLiteral(tpl, declared) {
+		return "{`" + render.RenderForLDApplicationTemplate(tpl, declared) + "`}"
+	}
+	bare, err := render.RenderForJSXText(tpl, declared)
+	if err != nil {
+		// 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) {
+	leadEnd := 0
+	for leadEnd < len(s) && isSpace(s[leadEnd]) {
+		leadEnd++
+	}
+	if leadEnd == len(s) {
+		return s, ""
+	}
+	trailStart := len(s)
+	for trailStart > leadEnd && isSpace(s[trailStart-1]) {
+		trailStart--
+	}
+	return s[:leadEnd], s[trailStart:]
+}
+
+func isSpace(b byte) bool { return b == ' ' || b == '\t' || b == '\n' || b == '\r' }
+
+func needsTemplateLiteral(tpl []render.Node, declared map[string]struct{}) bool {
+	if render.HasInterpolation(tpl, declared) {
+		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..28af7e9
--- /dev/null
+++ b/snippets/internal/adapters/ldapplication/ldapplication_test.go
@@ -0,0 +1,268 @@
+package ldapplication
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+// 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)
+	}
+}
+
+// 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)
+	}
+	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)
+	}
+	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)
+	}
+}
+
+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
new file mode 100644
index 0000000..515a6d4
--- /dev/null
+++ b/snippets/internal/markers/markers.go
@@ -0,0 +1,420 @@
+package markers
+
+import (
+	"crypto/sha256"
+	"encoding/hex"
+	"fmt"
+	"regexp"
+	"strings"
+)
+
+// 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[:])[:hashLen]
+}
+
+// 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 '"', '\'':
+			j = skipPlainString(src, j)
+			continue
+		case '`':
+			j = skipBacktick(src, j)
+			continue
+		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  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)
+	}
+
+	return Match{
+		Fields:       m,
+		Style:        style,
+		CommentStart: commentStart,
+		CommentEnd:   commentEnd,
+		OpenTagStart: openTagStart,
+		RegionStart:  regionStart,
+		RegionEnd:    regionEnd,
+		CloseTagEnd:  closeTagEnd,
+		TagName:      tag,
+	}, 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)
+	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..ffbb7a2
--- /dev/null
+++ b/snippets/internal/markers/markers_test.go
@@ -0,0 +1,185 @@
+package markers
+
+import (
+	"strings"
+	"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)) + } +} + +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) + } +} + +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 new file mode 100644 index 0000000..d03022f --- /dev/null +++ b/snippets/internal/model/model.go @@ -0,0 +1,180 @@ +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 { + // 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 +// 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 + 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]:] + + 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..5c41b4c --- /dev/null +++ b/snippets/internal/render/render.go @@ -0,0 +1,144 @@ +package render + +import ( + "fmt" + "strings" +) + +// RenderRuntime substitutes inputs as concrete values. Used by the validator. +// 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 { + switch x := n.(type) { + case *Literal: + sb.WriteString(x.Text) + case *Var: + v, ok := inputs[x.Name] + if !ok { + // 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 { + // 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) + if err != nil { + return "", err + } + sb.WriteString(inner) + } + } + } + return sb.String(), nil +} + +// 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: + if _, ok := declaredInputs[x.Name]; ok { + return true + } + case *Cond: + return true + } + } + 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. 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("}") + case *Cond: + sb.WriteString("${") + sb.WriteString(x.Var) + sb.WriteString(" ? `") + sb.WriteString(RenderForLDApplicationTemplate(x.Body, declaredInputs)) + 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 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; 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 { + 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") + } + } + 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 { + 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..f32e43d --- /dev/null +++ b/snippets/internal/render/template.go @@ -0,0 +1,89 @@ +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] + // 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.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..6790bae --- /dev/null +++ b/snippets/internal/render/template_test.go @@ -0,0 +1,192 @@ +package render + +import ( + "strings" + "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 { + 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 = 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) + } +} + +func TestRenderForLDApplicationTemplateEscapes(t *testing.T) { + nodes, _ := Parse("a \\ b ` c ${d} {{ name }}") + got := RenderForLDApplicationTemplate(nodes, allInputs("name")) + want := "a \\\\ b \\` c \\${d} ${name}" + if got != want { + t.Fatalf("escapes: got %q want %q", got, want) + } +} + +// 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 { + 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) + } +} + +// 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(), "undeclared input") { + t.Fatalf("want undeclared-conditional 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) + } +} + +// 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, allInputs()) + if err != nil { + t.Fatalf("render: %v", err) + } + if got != `python .\main.py` { + t.Fatalf("backslash mangled: %q", got) + } +} + +// 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, 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 := []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) + } + } +} + +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/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 new file mode 100644 index 0000000..599f4a2 --- /dev/null +++ b/snippets/internal/validate/validate.go @@ -0,0 +1,428 @@ +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" +) + +// 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) +} + +// 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) +// +// 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 { + 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) + 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 !isValidatable(s) { + continue + } + any = true + if err := runOne(cfg, s, snippets, env); 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 isValidatable(s *model.Snippet) bool { + return s.Frontmatter.Validation.Runtime != "" || s.Frontmatter.Validation.Entrypoint != "" +} + +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 runtime == "" { + return fmt.Errorf("snippet %q: cannot determine validator runtime (set validation.runtime or lang)", s.Frontmatter.ID) + } + + runner, runnerDir, err := loadRunner(cfg.ValidatorsDir, runtime) + if err != nil { + return err + } + + if err := requireEnvForInputs(s, all, env); err != nil { + return err + } + + 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 + } + + if err := stageOne(stageDir, entry, env); err != nil { + os.RemoveAll(stageDir) + return "", err + } + 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(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) +} + +// 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 +} + +// 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", + "-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) + } + 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 { + return fmt.Errorf("snippet runtime validation failed: %w", err) + } + return nil +} + +// 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 err := check(entry); err != nil { + return err + } + 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 +// 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 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() + 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) + } + err := filepath.WalkDir(root, 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(validatorsDir, p) + fmt.Fprintf(h, "%s\x00", rel) + _, err = io.Copy(h, f) + return err + }) + if err != nil { + return "", err + } + } + 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 new file mode 100644 index 0000000..fc8dace --- /dev/null +++ b/snippets/internal/validate/validate_test.go @@ -0,0 +1,115 @@ +package validate + +import ( + "strings" + "testing" + + "github.com/launchdarkly/sdk-meta/snippets/internal/model" +) + +// 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", + "/etc/passwd", + "..", + } + for _, e := range bad { + if err := checkStagePath(e); err == nil { + t.Errorf("checkStagePath(%q): expected error", e) + } + } + 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 := checkStagePath(e); err != nil { + t.Errorf("checkStagePath(%q): unexpected error %v", e, 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) + } + } +} + +// 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"}, + "mobileKey": {Type: "mobile-key"}, + "clientSideId": {Type: "client-side-id"}, + "version": {Type: "string", RuntimeDefault: "1.2.3"}, + }, + }, + } + got, err := runtimeInputs(s, env) + if err != nil { + t.Fatal(err) + } + 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", + } + 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", "mobile-key", "client-side-id"} { + s := &model.Snippet{ + Frontmatter: model.Frontmatter{ + Inputs: map[string]model.Input{ + "k": {Type: kind, RuntimeDefault: "should-not-be-here"}, + }, + }, + } + _, 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/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..760782e --- /dev/null +++ b/snippets/validators/languages/python/Dockerfile @@ -0,0 +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 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 new file mode 100755 index 0000000..f2abdf5 --- /dev/null +++ b/snippets/validators/languages/python/harness/run.sh @@ -0,0 +1,30 @@ +#!/bin/sh +# Runs the staged Python snippet against a real LaunchDarkly environment. +# Inputs (env): LAUNCHDARKLY_SDK_KEY, LAUNCHDARKLY_FLAG_KEY, SNIPPET_ENTRYPOINT. +set -eu + +. /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 +fi + +LOG=$(mktemp) +trap 'rm -f "$LOG"' EXIT +cd /snippet + +# 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 )) +if await_success_line "$LOG" "$PID" "$deadline"; then + exit 0 +fi + +kill -TERM "$PID" 2>/dev/null || true +wait "$PID" 2>/dev/null || true +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 +}