diff --git a/CLAUDE.md b/CLAUDE.md index 3930d769..faae380a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,13 +86,13 @@ Environment variables: `lstk snapshot` captures and restores the running emulator's state (AWS emulator only). Domain logic lives in `internal/snapshot/`; `cmd/snapshot.go` is wiring + output-mode selection. -- `lstk snapshot save [destination]` — export state to a local `.zip` or a named cloud snapshot. +- `lstk snapshot save [destination]` — export state to a local `.snapshot` file or a named cloud snapshot. - `lstk snapshot load REF` — restore state, starting the emulator first if needed; `--merge` controls how snapshot state combines with running state (`account-region-merge` (default), `overwrite`, `service-merge`). - `lstk snapshot list` — list cloud snapshots on the LocalStack platform. Lists only snapshots you created by default; pass `--all` to include every snapshot in your organization. Cloud-only; requires auth. - `lstk snapshot remove REF` — delete a cloud snapshot. Cloud-only; local files are never deleted by the CLI. Prompts for confirmation in interactive mode; `--force` is required to skip the prompt in non-interactive mode. A REF is parsed by helpers in `internal/snapshot/destination.go`: -- **local file** — absolute/relative path; `.zip` is appended if omitted. +- **local file** — absolute/relative path; the `.snapshot` extension is forced (any other extension is replaced). On load, `.zip` files saved by older lstk versions are still accepted. - **cloud snapshot** — `pod:` prefix (e.g. `pod:my-baseline`), stored on the LocalStack platform. Requires auth (`LOCALSTACK_AUTH_TOKEN` or `lstk login`). `ParseDestination` (save), `ParseSource` (load), and `ParseRemovable` (remove) share pod-name validation; `ParseRemovable` rejects local paths so the CLI cannot delete local files. diff --git a/README.md b/README.md index 6c3dac28..6957f519 100644 --- a/README.md +++ b/README.md @@ -238,7 +238,7 @@ lstk setup azure lstk az group list # Save emulator state to a local file -lstk snapshot save ./my-snapshot.zip +lstk snapshot save ./my-snapshot.snapshot # Save emulator state as a named cloud snapshot on the LocalStack platform lstk snapshot save pod:my-baseline @@ -269,12 +269,12 @@ Snapshots capture the running emulator's state so you can restore it later. A snapshot reference is either a **local file** or a **cloud snapshot**: -- **Local file** — an absolute or relative path. A `.zip` extension is added if omitted. +- **Local file** — an absolute or relative path. A `.snapshot` extension is added if omitted (snapshots saved as `.zip` by older lstk versions still load). - **Cloud snapshot** — a name with the `pod:` prefix (e.g. `pod:my-baseline`), stored on the LocalStack platform. Requires authentication (`LOCALSTACK_AUTH_TOKEN` or `lstk login`). ```bash # Save (local or cloud) -lstk snapshot save ./my-snapshot.zip +lstk snapshot save ./my-snapshot.snapshot lstk snapshot save pod:my-baseline # Load (starts the emulator first if needed) diff --git a/cmd/help_wrap_test.go b/cmd/help_wrap_test.go index 6b1956f4..90c86604 100644 --- a/cmd/help_wrap_test.go +++ b/cmd/help_wrap_test.go @@ -38,9 +38,9 @@ func TestWrapLine(t *testing.T) { }, { name: "indented example is left untouched even when over width", - line: " lstk snapshot save # saves to ./snapshot.zip", + line: " lstk snapshot save # saves to ./snapshot.snapshot", width: 20, - want: " lstk snapshot save # saves to ./snapshot.zip", + want: " lstk snapshot save # saves to ./snapshot.snapshot", }, { name: "tab-indented line is left untouched even when over width", diff --git a/cmd/snapshot.go b/cmd/snapshot.go index 2bb7b804..f0426fa9 100644 --- a/cmd/snapshot.go +++ b/cmd/snapshot.go @@ -41,9 +41,9 @@ const snapshotSaveLong = `Save a snapshot of the running emulator's state. Pass [destination] as an absolute or relative path for the exported file: - lstk snapshot save # saves to ./snapshot--.zip - lstk snapshot save ./my-snapshot.zip # saves to ./my-snapshot.zip - lstk snapshot save /tmp/my-state # saves to /tmp/my-state.zip + lstk snapshot save # saves to ./snapshot--.snapshot + lstk snapshot save ./my-snapshot.snapshot # saves to ./my-snapshot.snapshot + lstk snapshot save /tmp/my-state # saves to /tmp/my-state.snapshot To save to a remote pod on the LocalStack platform, use the pod: prefix: @@ -55,9 +55,9 @@ const snapshotLoadLong = `Load a snapshot into the running emulator, starting it REF identifies the snapshot to load: - lstk snapshot load my-baseline # loads ./my-baseline or ./my-baseline.zip - lstk snapshot load ./checkpoint.zip # loads from explicit path - lstk snapshot load pod:my-baseline # loads from LocalStack Cloud + lstk snapshot load my-baseline # loads ./my-baseline or ./my-baseline.snapshot + lstk snapshot load ./checkpoint.snapshot # loads from explicit path + lstk snapshot load pod:my-baseline # loads from LocalStack Cloud Merge strategies control how snapshot state is combined with running state: diff --git a/internal/emulator/aws/client.go b/internal/emulator/aws/client.go index 125bb3b8..4d8e78b9 100644 --- a/internal/emulator/aws/client.go +++ b/internal/emulator/aws/client.go @@ -221,12 +221,24 @@ func (c *Client) ImportState(ctx context.Context, host string, src io.Reader, st continue } if event.Status == "error" && event.Message != "" { + if isInvalidSnapshotFileMsg(event.Message) { + return snapshot.ErrInvalidSnapshotFile + } return fmt.Errorf("load failed for service %s: %s", event.Service, event.Message) } } return scanner.Err() } +// isInvalidSnapshotFileMsg reports whether an emulator error message indicates +// the source could not be read as a snapshot archive. We translate these into +// snapshot.ErrInvalidSnapshotFile so the user-facing message never leaks the +// underlying archive format. +func isInvalidSnapshotFileMsg(msg string) bool { + m := strings.ToLower(msg) + return strings.Contains(m, "not a valid zip archive") || strings.Contains(m, "invalid pod file") +} + func (c *Client) LoadPodSnapshot(ctx context.Context, host, podName, authToken, strategy string) ([]string, error) { url := fmt.Sprintf("http://%s/_localstack/pods/%s", host, podName) if strategy != "" { @@ -278,10 +290,16 @@ func (c *Client) LoadPodSnapshot(ctx context.Context, host, podName, authToken, case "ok": services = append(services, event.Service) case "error": + if isInvalidSnapshotFileMsg(event.Message) { + return nil, snapshot.ErrInvalidSnapshotFile + } return nil, fmt.Errorf("load failed for service %s: %s", event.Service, event.Message) } case "completion": if event.Status != "ok" { + if isInvalidSnapshotFileMsg(event.Message) { + return nil, snapshot.ErrInvalidSnapshotFile + } return nil, fmt.Errorf("pod load failed: %s", event.Message) } return services, nil diff --git a/internal/output/events.go b/internal/output/events.go index f224012f..66a9015d 100644 --- a/internal/output/events.go +++ b/internal/output/events.go @@ -91,7 +91,7 @@ type DeferredEvent struct { } type SnapshotLoadedEvent struct { - Source string // display source shown to the user (e.g. "./snap.zip" or "pod:my-baseline") + Source string // display source shown to the user (e.g. "./snap.snapshot" or "pod:my-baseline") Services []string // services restored } diff --git a/internal/output/plain_format_test.go b/internal/output/plain_format_test.go index 9f337fe2..46c4eff3 100644 --- a/internal/output/plain_format_test.go +++ b/internal/output/plain_format_test.go @@ -214,14 +214,14 @@ func TestFormatEventLine(t *testing.T) { // snapshot load events { name: "snapshot loaded with services", - event: SnapshotLoadedEvent{Source: "./my-baseline.zip", Services: []string{"s3", "dynamodb"}}, - want: SuccessMarker() + " Snapshot loaded from ./my-baseline.zip\n• Services: s3, dynamodb", + event: SnapshotLoadedEvent{Source: "./my-baseline.snapshot", Services: []string{"s3", "dynamodb"}}, + want: SuccessMarker() + " Snapshot loaded from ./my-baseline.snapshot\n• Services: s3, dynamodb", wantOK: true, }, { name: "snapshot loaded no services", - event: SnapshotLoadedEvent{Source: "./snap.zip"}, - want: SuccessMarker() + " Snapshot loaded from ./snap.zip", + event: SnapshotLoadedEvent{Source: "./snap.snapshot"}, + want: SuccessMarker() + " Snapshot loaded from ./snap.snapshot", wantOK: true, }, { diff --git a/internal/snapshot/destination.go b/internal/snapshot/destination.go index 4d0f0a9f..c9835601 100644 --- a/internal/snapshot/destination.go +++ b/internal/snapshot/destination.go @@ -23,6 +23,20 @@ var ( validPodName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9-]*$`) ) +const ( + snapshotExt = ".snapshot" // user-facing extension for local snapshots + legacySnapshotExt = ".zip" // accepted on load for backward compatibility +) + +// withSnapshotExt forces the .snapshot extension, replacing any other the user gave. +func withSnapshotExt(path string) string { + ext := filepath.Ext(path) + if strings.EqualFold(ext, snapshotExt) { + return path + } + return strings.TrimSuffix(path, ext) + snapshotExt +} + // DestinationKind distinguishes local file paths from remote pod destinations. type DestinationKind int @@ -32,7 +46,7 @@ const ( ) // Destination is the parsed result of a user-supplied snapshot destination. -// For KindLocal, Value is an absolute local file path with a .zip extension. +// For KindLocal, Value is an absolute local file path with a .snapshot extension. // For KindPod, Value is the validated pod name (without the "pod:" prefix). type Destination struct { Kind DestinationKind @@ -46,9 +60,7 @@ func ParseRemovable(ref, cwd, home string) (Destination, error) { lower := strings.ToLower(ref) if !strings.HasPrefix(lower, "pod:") && !strings.Contains(lower, "://") { abs, _ := filepath.Abs(ref) - if !strings.EqualFold(filepath.Ext(abs), ".zip") { - abs += ".zip" - } + abs = withSnapshotExt(abs) return Destination{}, fmt.Errorf("'%s' resolves to a local file (%s); CLI cannot delete local files", ref, displayPath(abs, cwd, home)) } return ParseSource(ref, home) @@ -72,7 +84,8 @@ func displayPath(abs, cwd, home string) string { // ParseSource resolves a user-supplied source REF for loading a snapshot. // Unlike ParseDestination it never auto-generates a name: REF is required. -// For local paths, the file must exist; if no extension is given, .zip is tried as a fallback. +// For local paths, the file must exist; if no matching file is found, .snapshot and +// then .zip (legacy) are tried as fallbacks. // home is used to expand a leading "~" or "~/"; pass "" to disable tilde expansion. func ParseSource(ref, home string) (Destination, error) { if ref == "" { @@ -110,8 +123,9 @@ func ParseSource(ref, home string) (Destination, error) { return Destination{}, fmt.Errorf("resolve path: %w", err) } - // Try the path as-is first, then with .zip appended as a fallback for bare - // names (e.g. "my-snapshot" → "my-snapshot.zip" since that is what save produces). + // Try the path as-is first, then with .snapshot appended as a fallback for bare + // names (e.g. "my-snapshot" → "my-snapshot.snapshot" since that is what save + // produces), and finally .zip for snapshots saved by older lstk versions. resolved, err := resolveSourcePath(abs) if err != nil { return Destination{}, err @@ -119,16 +133,17 @@ func ParseSource(ref, home string) (Destination, error) { return Destination{Kind: KindLocal, Value: resolved}, nil } -// resolveSourcePath returns the first existing path among: abs as-is, then abs+".zip". +// resolveSourcePath returns the first existing path among: abs as-is, then +// abs+".snapshot", then abs+".zip" (legacy). func resolveSourcePath(abs string) (string, error) { - if _, err := os.Stat(abs); err == nil { - return abs, nil - } - withZip := abs + ".zip" - if _, err := os.Stat(withZip); err == nil { - return withZip, nil + withSnapshot := abs + snapshotExt + withZip := abs + legacySnapshotExt + for _, candidate := range []string{abs, withSnapshot, withZip} { + if _, err := os.Stat(candidate); err == nil { + return candidate, nil + } } - return "", fmt.Errorf("snapshot file not found: %q (also tried %q)", abs, withZip) + return "", fmt.Errorf("snapshot file not found: %q (also tried %q and %q)", abs, withSnapshot, withZip) } // ParseDestination resolves a user-supplied destination to a local path (KindLocal) or validated pod name (KindPod). @@ -187,9 +202,7 @@ func ParseDestination(dest, home string, now time.Time) (Destination, error) { return Destination{}, fmt.Errorf("%q is a directory — specify a file path like ./my-snapshot", abs) } - if !strings.EqualFold(filepath.Ext(abs), ".zip") { - abs += ".zip" - } + abs = withSnapshotExt(abs) return Destination{Kind: KindLocal, Value: abs}, nil } diff --git a/internal/snapshot/destination_test.go b/internal/snapshot/destination_test.go index bbcc111e..bba43f3a 100644 --- a/internal/snapshot/destination_test.go +++ b/internal/snapshot/destination_test.go @@ -25,11 +25,15 @@ func TestParseSource(t *testing.T) { home := t.TempDir() dir := t.TempDir() - existingZip := filepath.Join(dir, "snap.zip") + existingSnapshot := filepath.Join(dir, "snap.snapshot") + require.NoError(t, os.WriteFile(existingSnapshot, []byte("data"), 0600)) + existingZip := filepath.Join(dir, "legacy.zip") // saved by an older lstk version require.NoError(t, os.WriteFile(existingZip, []byte("data"), 0600)) - existingBare := filepath.Join(dir, "bare") // no extension — snap.zip fallback exists - require.NoError(t, os.WriteFile(existingBare+".zip", []byte("data"), 0600)) - existingNoExt := filepath.Join(dir, "noext") // no extension, no .zip counterpart either + existingBare := filepath.Join(dir, "bare") // no extension — .snapshot fallback exists + require.NoError(t, os.WriteFile(existingBare+".snapshot", []byte("data"), 0600)) + existingLegacyBare := filepath.Join(dir, "legacybare") // only a .zip counterpart exists + require.NoError(t, os.WriteFile(existingLegacyBare+".zip", []byte("data"), 0600)) + existingNoExt := filepath.Join(dir, "noext") // no extension, no fallback counterpart either require.NoError(t, os.WriteFile(existingNoExt, []byte("data"), 0600)) type testCase struct { @@ -53,26 +57,38 @@ func TestParseSource(t *testing.T) { // --- local paths (file must exist) --- { - name: "explicit .zip path", + name: "explicit .snapshot path", + input: existingSnapshot, + wantKind: snapshot.KindLocal, + wantPath: existingSnapshot, + }, + { + name: "explicit legacy .zip path", input: existingZip, wantKind: snapshot.KindLocal, wantPath: existingZip, }, { - name: "bare name resolves to .zip fallback", + name: "bare name resolves to .snapshot fallback", input: existingBare, wantKind: snapshot.KindLocal, - wantPath: existingBare + ".zip", + wantPath: existingBare + ".snapshot", + }, + { + name: "bare name resolves to legacy .zip fallback", + input: existingLegacyBare, + wantKind: snapshot.KindLocal, + wantPath: existingLegacyBare + ".zip", }, { - name: "file without .zip extension resolves as-is", + name: "file without extension resolves as-is", input: existingNoExt, wantKind: snapshot.KindLocal, wantPath: existingNoExt, }, { name: "nonexistent file returns error", - input: filepath.Join(dir, "missing.zip"), + input: filepath.Join(dir, "missing.snapshot"), wantErr: "snapshot file not found", }, { @@ -317,19 +333,19 @@ func TestParseDestination(t *testing.T) { name: "default", input: "", wantKind: snapshot.KindLocal, - wantPathRegexp: regexp.QuoteMeta(filepath.Join(wd, "snapshot-2026-05-11T21-04-32-")) + `[0-9a-f]{3}\.zip`, + wantPathRegexp: regexp.QuoteMeta(filepath.Join(wd, "snapshot-2026-05-11T21-04-32-")) + `[0-9a-f]{3}\.snapshot`, }, // --- local paths --- { input: "./my-state", wantKind: snapshot.KindLocal, - wantPath: filepath.Join(wd, "my-state.zip"), + wantPath: filepath.Join(wd, "my-state.snapshot"), }, { input: filepath.Join(os.TempDir(), "state"), wantKind: snapshot.KindLocal, - wantPath: filepath.Join(os.TempDir(), "state.zip"), + wantPath: filepath.Join(os.TempDir(), "state.snapshot"), }, { input: "~", @@ -339,29 +355,43 @@ func TestParseDestination(t *testing.T) { // parent (~/) always exists input: "~/my-state", wantKind: snapshot.KindLocal, - wantPath: filepath.Join(home, "my-state.zip"), + wantPath: filepath.Join(home, "my-state.snapshot"), }, { name: "relative path with existing subdir", input: filepath.Join(subDir, "state"), wantKind: snapshot.KindLocal, - wantPath: filepath.Join(subDir, "state.zip"), + wantPath: filepath.Join(subDir, "state.snapshot"), }, { // bare name: treated as relative to CWD, not a pod input: "my-pod", wantKind: snapshot.KindLocal, - wantPath: filepath.Join(wd, "my-pod.zip"), + wantPath: filepath.Join(wd, "my-pod.snapshot"), + }, + { + name: "explicit .snapshot extension kept", + input: "./checkpoint.snapshot", + wantKind: snapshot.KindLocal, + wantPath: filepath.Join(wd, "checkpoint.snapshot"), + }, + { + name: "uppercase .SNAPSHOT extension kept as-is", + input: "./already.SNAPSHOT", + wantKind: snapshot.KindLocal, + wantPath: filepath.Join(wd, "already.SNAPSHOT"), }, { + name: "explicit .zip extension forced to .snapshot", input: "./checkpoint.zip", wantKind: snapshot.KindLocal, - wantPath: filepath.Join(wd, "checkpoint.zip"), + wantPath: filepath.Join(wd, "checkpoint.snapshot"), }, { - input: "./already.ZIP", + name: "other extension forced to .snapshot", + input: "./backup.tar", wantKind: snapshot.KindLocal, - wantPath: filepath.Join(wd, "already.ZIP"), + wantPath: filepath.Join(wd, "backup.snapshot"), }, // --- parent directory does not exist --- @@ -453,19 +483,19 @@ func TestParseDestination(t *testing.T) { testCase{ input: `~\my-state`, wantKind: snapshot.KindLocal, - wantPath: filepath.Join(home, "my-state.zip"), + wantPath: filepath.Join(home, "my-state.snapshot"), }, testCase{ name: "windows abs backslash", input: filepath.Join(tmpParent, "snap"), wantKind: snapshot.KindLocal, - wantPath: filepath.Join(tmpParent, "snap.zip"), + wantPath: filepath.Join(tmpParent, "snap.snapshot"), }, testCase{ name: "windows abs forward-slash", input: strings.ReplaceAll(filepath.Join(tmpParent, "snap"), `\`, `/`), wantKind: snapshot.KindLocal, - wantPath: filepath.Join(tmpParent, "snap.zip"), + wantPath: filepath.Join(tmpParent, "snap.snapshot"), }, ) } diff --git a/internal/snapshot/load.go b/internal/snapshot/load.go index 86c8c175..ffdd3f64 100644 --- a/internal/snapshot/load.go +++ b/internal/snapshot/load.go @@ -23,6 +23,11 @@ const ( var ErrIncompatibleSnapshot = errors.New("snapshot is incompatible with the running LocalStack version") +// ErrInvalidSnapshotFile indicates the source could not be read as a snapshot +// (e.g. a non-snapshot file was passed). It deliberately hides the underlying +// archive format from the user-facing message. +var ErrInvalidSnapshotFile = errors.New("not a valid snapshot file") + func ValidateMergeStrategy(strategy string) error { switch strategy { case MergeStrategyAccountRegion, MergeStrategyOverwrite, MergeStrategyService: @@ -97,6 +102,13 @@ func load(ctx context.Context, rt runtime.Runtime, containers []config.Container }) return output.NewSilentError(err) } + if errors.Is(err, ErrInvalidSnapshotFile) { + sink.Emit(output.ErrorEvent{ + Title: "Could not load snapshot", + Summary: "This file is not a valid snapshot", + }) + return output.NewSilentError(err) + } return err } diff --git a/test/integration/snapshot_load_test.go b/test/integration/snapshot_load_test.go index 60932652..c82f47b9 100644 --- a/test/integration/snapshot_load_test.go +++ b/test/integration/snapshot_load_test.go @@ -37,6 +37,24 @@ func mockLocalLoadServer(t *testing.T) (*httptest.Server, func() bool) { return srv, resetCalled.Load } +// mockLocalLoadInvalidFileServer returns a test server whose import endpoint +// streams the emulator's BadZipFile error event, mimicking what the emulator +// returns when the source is not a valid snapshot archive. +func mockLocalLoadInvalidFileServer(t *testing.T) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && r.URL.Path == "/_localstack/pods" { + w.Header().Set("Content-Type", "application/x-ndjson") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"error","message":"Invalid pod file: File is not a valid zip archive"}` + "\n")) + return + } + w.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(srv.Close) + return srv +} + // mockPodLoadServer returns a test server that handles PUT /_localstack/pods/{name}. // respondOK controls whether it emits a success or error completion event. func mockPodLoadServer(t *testing.T, respondOK bool) *httptest.Server { @@ -120,7 +138,7 @@ func TestSnapshotLoadFileNotFound(t *testing.T) { _, stderr, err := runLstk(t, ctx, t.TempDir(), testEnvWithHome(t.TempDir(), ""), - "--non-interactive", "snapshot", "load", "/no/such/snapshot.zip", + "--non-interactive", "snapshot", "load", "/no/such/snapshot.snapshot", ) requireExitCode(t, 1, err) assert.Contains(t, stderr, "snapshot file not found") @@ -138,7 +156,7 @@ func TestSnapshotLoadLocalSuccess(t *testing.T) { srv, _ := mockLocalLoadServer(t) dir := t.TempDir() - snapPath := writeTestSnapFile(t, dir, "snap.zip") + snapPath := writeTestSnapFile(t, dir, "snap.snapshot") stdout, stderr, err := runLstk(t, ctx, dir, env.Environ(testEnvWithHome(t.TempDir(), "")).With(env.LocalStackHost, lsHost(srv)), @@ -158,8 +176,8 @@ func TestSnapshotLoadLocalBareNameFallback(t *testing.T) { srv, _ := mockLocalLoadServer(t) dir := t.TempDir() - // Create snap.zip; pass bare name "snap" — ParseSource should resolve to snap.zip. - writeTestSnapFile(t, dir, "snap.zip") + // Create snap.snapshot; pass bare name "snap" — ParseSource should resolve to snap.snapshot. + writeTestSnapFile(t, dir, "snap.snapshot") stdout, stderr, err := runLstk(t, ctx, dir, env.Environ(testEnvWithHome(t.TempDir(), "")).With(env.LocalStackHost, lsHost(srv)), @@ -169,6 +187,52 @@ func TestSnapshotLoadLocalBareNameFallback(t *testing.T) { assert.Contains(t, stdout, "Snapshot loaded") } +// TestSnapshotLoadLocalLegacyZipFallback verifies that snapshots saved as .zip by +// older lstk versions still load by bare name. +func TestSnapshotLoadLocalLegacyZipFallback(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + startTestContainer(t, ctx) + srv, _ := mockLocalLoadServer(t) + + dir := t.TempDir() + // Only a legacy snap.zip exists; pass bare name "snap" — ParseSource should still find it. + writeTestSnapFile(t, dir, "snap.zip") + + stdout, stderr, err := runLstk(t, ctx, dir, + env.Environ(testEnvWithHome(t.TempDir(), "")).With(env.LocalStackHost, lsHost(srv)), + "--non-interactive", "snapshot", "load", filepath.Join(dir, "snap"), + ) + require.NoError(t, err, "legacy .zip fallback failed: %s", stderr) + assert.Contains(t, stdout, "Snapshot loaded") +} + +func TestSnapshotLoadLocalInvalidFile(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + startTestContainer(t, ctx) + srv := mockLocalLoadInvalidFileServer(t) + + dir := t.TempDir() + snapPath := writeTestSnapFile(t, dir, "snap.snapshot") + + stdout, stderr, err := runLstk(t, ctx, dir, + env.Environ(testEnvWithHome(t.TempDir(), "")).With(env.LocalStackHost, lsHost(srv)), + "--non-interactive", "snapshot", "load", snapPath, + ) + requireExitCode(t, 1, err) + // The user-facing error is emitted through the sink (stdout); the underlying + // "zip archive" detail must not leak to the user. + assert.Contains(t, stdout, "not a valid snapshot") + assert.NotContains(t, strings.ToLower(stdout+stderr), "zip") +} + func TestSnapshotLoadLocalOverwriteStrategy(t *testing.T) { requireDocker(t) cleanup() @@ -179,7 +243,7 @@ func TestSnapshotLoadLocalOverwriteStrategy(t *testing.T) { srv, wasReset := mockLocalLoadServer(t) dir := t.TempDir() - snapPath := writeTestSnapFile(t, dir, "snap.zip") + snapPath := writeTestSnapFile(t, dir, "snap.snapshot") _, stderr, err := runLstk(t, ctx, dir, env.Environ(testEnvWithHome(t.TempDir(), "")).With(env.LocalStackHost, lsHost(srv)), @@ -189,7 +253,6 @@ func TestSnapshotLoadLocalOverwriteStrategy(t *testing.T) { assert.True(t, wasReset(), "/_localstack/state/reset should have been called for overwrite strategy") } - func TestSnapshotLoadPodSuccess(t *testing.T) { requireDocker(t) cleanup() @@ -241,7 +304,7 @@ func TestSnapshotLoadTelemetryEmitted(t *testing.T) { srv, _ := mockLocalLoadServer(t) dir := t.TempDir() - snapPath := writeTestSnapFile(t, dir, "snap.zip") + snapPath := writeTestSnapFile(t, dir, "snap.snapshot") analyticsSrv, events := mockAnalyticsServer(t) _, stderr, err := runLstk(t, ctx, dir, @@ -264,7 +327,7 @@ func TestSnapshotLoadInteractive(t *testing.T) { srv, _ := mockLocalLoadServer(t) dir := t.TempDir() - snapPath := writeTestSnapFile(t, dir, "snap.zip") + snapPath := writeTestSnapFile(t, dir, "snap.snapshot") out, err := runLstkInPTY(t, ctx, env.Environ(testEnvWithHome(t.TempDir(), "")).With(env.LocalStackHost, lsHost(srv)), @@ -284,7 +347,7 @@ func TestLoadAliasMatchesSnapshotLoad(t *testing.T) { srv, _ := mockLocalLoadServer(t) dir := t.TempDir() - snapPath := writeTestSnapFile(t, dir, "snap.zip") + snapPath := writeTestSnapFile(t, dir, "snap.snapshot") analyticsSrv, events := mockAnalyticsServer(t) stdout, stderr, err := runLstk(t, ctx, dir, diff --git a/test/integration/snapshot_save_test.go b/test/integration/snapshot_save_test.go index aa5af30b..a429cc39 100644 --- a/test/integration/snapshot_save_test.go +++ b/test/integration/snapshot_save_test.go @@ -65,12 +65,12 @@ func TestSnapshotSaveDefaultDestination(t *testing.T) { require.NoError(t, readErr) var found bool for _, e := range entries { - if strings.HasPrefix(e.Name(), "snapshot-") { + if strings.HasPrefix(e.Name(), "snapshot-") && strings.HasSuffix(e.Name(), ".snapshot") { found = true break } } - assert.True(t, found, "default snapshot file (snapshot-*) should exist in %s", dir) + assert.True(t, found, "default snapshot file (snapshot-*.snapshot) should exist in %s", dir) } func TestSnapshotSaveCustomPath(t *testing.T) { @@ -82,7 +82,7 @@ func TestSnapshotSaveCustomPath(t *testing.T) { startTestContainer(t, ctx) srv := mockStateServer(t) dir := t.TempDir() - outPath := filepath.Join(dir, "my-snap.zip") + outPath := filepath.Join(dir, "my-snap.snapshot") stdout, stderr, err := runLstk(t, ctx, dir, env.Environ(testEnvWithHome(t.TempDir(), "")).With(env.LocalStackHost, lsHost(srv)), @@ -90,7 +90,7 @@ func TestSnapshotSaveCustomPath(t *testing.T) { ) require.NoError(t, err, "lstk snapshot save failed: %s", stderr) assert.Contains(t, stdout, "Snapshot saved") - assert.Contains(t, stdout, "./my-snap.zip") + assert.Contains(t, stdout, "./my-snap.snapshot") data, err := os.ReadFile(outPath) require.NoError(t, err, "output file should exist") @@ -118,10 +118,35 @@ func TestSnapshotSaveRelativePath(t *testing.T) { require.NoError(t, err, "lstk snapshot save failed: %s", stderr) assert.Contains(t, stdout, "Snapshot saved") - _, statErr := os.Stat(filepath.Join(dir, "my-state.zip")) + _, statErr := os.Stat(filepath.Join(dir, "my-state.snapshot")) assert.NoError(t, statErr, "relative output file should exist") } +// TestSnapshotSaveForcesSnapshotExtension verifies that a user-supplied extension +// is replaced with .snapshot rather than honored verbatim. +func TestSnapshotSaveForcesSnapshotExtension(t *testing.T) { + requireDocker(t) + cleanup() + t.Cleanup(cleanup) + + ctx := testContext(t) + startTestContainer(t, ctx) + srv := mockStateServer(t) + dir := t.TempDir() + + stdout, stderr, err := runLstk(t, ctx, dir, + env.Environ(testEnvWithHome(t.TempDir(), "")).With(env.LocalStackHost, lsHost(srv)), + "--non-interactive", "snapshot", "save", "./x.zip", + ) + require.NoError(t, err, "lstk snapshot save failed: %s", stderr) + assert.Contains(t, stdout, "./x.snapshot") + + _, statErr := os.Stat(filepath.Join(dir, "x.snapshot")) + assert.NoError(t, statErr, "extension should be forced to .snapshot") + _, zipErr := os.Stat(filepath.Join(dir, "x.zip")) + assert.True(t, os.IsNotExist(zipErr), "the user-supplied .zip path should not be created") +} + func TestSnapshotSaveOverwritesExistingFile(t *testing.T) { requireDocker(t) cleanup() @@ -131,7 +156,7 @@ func TestSnapshotSaveOverwritesExistingFile(t *testing.T) { startTestContainer(t, ctx) srv := mockStateServer(t) dir := t.TempDir() - outPath := filepath.Join(dir, "snap.zip") + outPath := filepath.Join(dir, "snap.snapshot") require.NoError(t, os.WriteFile(outPath, []byte("OLD"), 0600)) _, stderr, err := runLstk(t, ctx, dir, @@ -352,7 +377,7 @@ func TestSaveAliasMatchesSnapshotSave(t *testing.T) { startTestContainer(t, ctx) srv := mockStateServer(t) dir := t.TempDir() - outPath := filepath.Join(dir, "alias.zip") + outPath := filepath.Join(dir, "alias.snapshot") analyticsSrv, events := mockAnalyticsServer(t) stdout, stderr, err := runLstk(t, ctx, dir,