feat(app): add isolated development environments (#253)
* feat(app): add isolated development environments * fix(dev): make environment tests portable
- commit
- 9bf07496d182d7d5ebd8528ffe10a80147c794db
- author
- bnema · · 2026-09-04 09:10 UTC
- committer
- GitHub · · 2026-09-04 09:10 UTC
- parent
- bddd2db
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
7 files · +426 -0
M .gitignore +1 -0
| @@ -1,4 +1,5 @@ | |||
| 1 | 1 | /vev | |
| 2 | + | .dev/ | |
| 2 | 3 | *.test | |
| 3 | 4 | .superpowers/ | |
| 4 | 5 | docs/superpowers/ | |
| @@ -1,4 +1,5 @@ | |||||
| 1 | /vev | 1 | /vev | ||
| 2 | + | .dev/ | |||
| 2 | *.test | 3 | *.test | ||
| 3 | .superpowers/ | 4 | .superpowers/ | ||
| 4 | docs/superpowers/ | 5 | docs/superpowers/ | ||
M README.md +10 -0
| @@ -101,6 +101,16 @@ Use `vev cmd --help` for commands and targeting options. | |||
| 101 | 101 | ||
| 102 | 102 | ## Development | |
| 103 | 103 | ||
| 104 | + | Use `VEV_ENV` to keep development runs separate from your installed vev and from other builds: | |
| 105 | + | ||
| 106 | + | ```sh | |
| 107 | + | VEV_ENV=dev go run . | |
| 108 | + | VEV_ENV=dev go run . kill --all | |
| 109 | + | rm -rf .dev/dev | |
| 110 | + | ``` | |
| 111 | + | ||
| 112 | + | Each name shares one daemon and its vev-owned files under `.dev/<name>/`; use separate names to run binaries side by side. This isolates vev configuration, runtime files, durable sessions, hosts, snapshots, and logs, but it is not an OS, filesystem, or network sandbox. | |
| 113 | + | ||
| 104 | 114 | ```sh | |
| 105 | 115 | make test # go test ./... -race | |
| 106 | 116 | make lint # goimports check, go vet | |
| @@ -101,6 +101,16 @@ Use `vev cmd --help` for commands and targeting options. | |||||
| 101 | 101 | ||||
| 102 | ## Development | 102 | ## Development | ||
| 103 | 103 | ||||
| 104 | + | Use `VEV_ENV` to keep development runs separate from your installed vev and from other builds: | |||
| 105 | + | ||||
| 106 | + | ```sh | |||
| 107 | + | VEV_ENV=dev go run . | |||
| 108 | + | VEV_ENV=dev go run . kill --all | |||
| 109 | + | rm -rf .dev/dev | |||
| 110 | + | ``` | |||
| 111 | + | ||||
| 112 | + | Each name shares one daemon and its vev-owned files under `.dev/<name>/`; use separate names to run binaries side by side. This isolates vev configuration, runtime files, durable sessions, hosts, snapshots, and logs, but it is not an OS, filesystem, or network sandbox. | |||
| 113 | + | ||||
| 104 | ```sh | 114 | ```sh | ||
| 105 | make test # go test ./... -race | 115 | make test # go test ./... -race | ||
| 106 | make lint # goimports check, go vet | 116 | make lint # goimports check, go vet | ||
M docs/configuration.md +14 -0
| @@ -79,6 +79,20 @@ code.detach = DET | |||
| 79 | 79 | ||
| 80 | 80 | Invalid values log a warning and resolve that setting to its default on both initial load and reload. | |
| 81 | 81 | ||
| 82 | + | ## Development environments | |
| 83 | + | ||
| 84 | + | Set `VEV_ENV` to a name such as `dev` to redirect all vev-owned paths into `.dev/<name>/` under the directory where vev was invoked. The name must be one safe segment of at most 64 ASCII letters, digits, dots, underscores, or hyphens, and must start with a letter or digit. | |
| 85 | + | ||
| 86 | + | ```sh | |
| 87 | + | VEV_ENV=dev go run . | |
| 88 | + | VEV_ENV=dev go run . kill --all | |
| 89 | + | rm -rf .dev/dev | |
| 90 | + | ``` | |
| 91 | + | ||
| 92 | + | The selected absolute root is retained by the detached daemon and inherited vev processes even when their working directory changes. A name isolates configuration, runtime files, durable sessions, hosts, snapshots, and logs from normal XDG locations and from other names. Invocations using the same name intentionally share a daemon and files; use separate names for side-by-side binaries. | |
| 93 | + | ||
| 94 | + | This feature isolates vev-owned data only. It does not sandbox pane processes or provide OS, filesystem, or network isolation. | |
| 95 | + | ||
| 82 | 96 | ## Remote hosts | |
| 83 | 97 | ||
| 84 | 98 | Remote host commands, listing, and successful direct-attach learning are always active. | |
| @@ -79,6 +79,20 @@ code.detach = DET | |||||
| 79 | 79 | ||||
| 80 | Invalid values log a warning and resolve that setting to its default on both initial load and reload. | 80 | Invalid values log a warning and resolve that setting to its default on both initial load and reload. | ||
| 81 | 81 | ||||
| 82 | + | ## Development environments | |||
| 83 | + | ||||
| 84 | + | Set `VEV_ENV` to a name such as `dev` to redirect all vev-owned paths into `.dev/<name>/` under the directory where vev was invoked. The name must be one safe segment of at most 64 ASCII letters, digits, dots, underscores, or hyphens, and must start with a letter or digit. | |||
| 85 | + | ||||
| 86 | + | ```sh | |||
| 87 | + | VEV_ENV=dev go run . | |||
| 88 | + | VEV_ENV=dev go run . kill --all | |||
| 89 | + | rm -rf .dev/dev | |||
| 90 | + | ``` | |||
| 91 | + | ||||
| 92 | + | The selected absolute root is retained by the detached daemon and inherited vev processes even when their working directory changes. A name isolates configuration, runtime files, durable sessions, hosts, snapshots, and logs from normal XDG locations and from other names. Invocations using the same name intentionally share a daemon and files; use separate names for side-by-side binaries. | |||
| 93 | + | ||||
| 94 | + | This feature isolates vev-owned data only. It does not sandbox pane processes or provide OS, filesystem, or network isolation. | |||
| 95 | + | ||||
| 82 | ## Remote hosts | 96 | ## Remote hosts | ||
| 83 | 97 | ||||
| 84 | Remote host commands, listing, and successful direct-attach learning are always active. | 98 | Remote host commands, listing, and successful direct-attach learning are always active. | ||
M internal/app/run.go +21 -0
| @@ -145,6 +145,9 @@ func versionLine() string { | |||
| 145 | 145 | // Run is the entry point invoked by main. It parses args into a command and | |
| 146 | 146 | // dispatches it. | |
| 147 | 147 | func Run(args []string) error { | |
| 148 | + | if err := platform.ActivateDevelopmentEnvironment(); err != nil { | |
| 149 | + | return err | |
| 150 | + | } | |
| 148 | 151 | cmd, err := parseArgs(args) | |
| 149 | 152 | if err != nil { | |
| 150 | 153 | return err | |
| @@ -479,6 +482,17 @@ func snapshotDir() string { | |||
| 479 | 482 | return filepath.Join(platform.StateDir(), "snapshots") | |
| 480 | 483 | } | |
| 481 | 484 | ||
| 485 | + | func developmentTempDirOption(ensurePrivate func(string) error) (daemon.Option, error) { | |
| 486 | + | dir, active := platform.DevelopmentEnvironmentTempDir() | |
| 487 | + | if !active { | |
| 488 | + | return nil, nil | |
| 489 | + | } | |
| 490 | + | if err := ensurePrivate(dir); err != nil { | |
| 491 | + | return nil, fmt.Errorf("vev: secure development temp directory: %w", err) | |
| 492 | + | } | |
| 493 | + | return daemon.WithTempDir(dir), nil | |
| 494 | + | } | |
| 495 | + | ||
| 482 | 496 | func pprofAddrIsLoopback(addr string) bool { | |
| 483 | 497 | host, _, err := net.SplitHostPort(addr) | |
| 484 | 498 | if err != nil || host == "" { | |
| @@ -640,6 +654,13 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err | |||
| 640 | 654 | } | |
| 641 | 655 | ||
| 642 | 656 | daemonOpts := []daemon.Option{remoteDiscoveryOpt} | |
| 657 | + | developmentTempOpt, err := developmentTempDirOption(safedir.EnsurePrivate) | |
| 658 | + | if err != nil { | |
| 659 | + | return err | |
| 660 | + | } | |
| 661 | + | if developmentTempOpt != nil { | |
| 662 | + | daemonOpts = append(daemonOpts, developmentTempOpt) | |
| 663 | + | } | |
| 643 | 664 | if observer != nil { | |
| 644 | 665 | daemonOpts = append(daemonOpts, daemon.WithRuntimeObserver(observer)) | |
| 645 | 666 | } | |
| @@ -145,6 +145,9 @@ func versionLine() string { | |||||
| 145 | // Run is the entry point invoked by main. It parses args into a command and | 145 | // Run is the entry point invoked by main. It parses args into a command and | ||
| 146 | // dispatches it. | 146 | // dispatches it. | ||
| 147 | func Run(args []string) error { | 147 | func Run(args []string) error { | ||
| 148 | + | if err := platform.ActivateDevelopmentEnvironment(); err != nil { | |||
| 149 | + | return err | |||
| 150 | + | } | |||
| 148 | cmd, err := parseArgs(args) | 151 | cmd, err := parseArgs(args) | ||
| 149 | if err != nil { | 152 | if err != nil { | ||
| 150 | return err | 153 | return err | ||
| @@ -479,6 +482,17 @@ func snapshotDir() string { | |||||
| 479 | return filepath.Join(platform.StateDir(), "snapshots") | 482 | return filepath.Join(platform.StateDir(), "snapshots") | ||
| 480 | } | 483 | } | ||
| 481 | 484 | ||||
| 485 | + | func developmentTempDirOption(ensurePrivate func(string) error) (daemon.Option, error) { | |||
| 486 | + | dir, active := platform.DevelopmentEnvironmentTempDir() | |||
| 487 | + | if !active { | |||
| 488 | + | return nil, nil | |||
| 489 | + | } | |||
| 490 | + | if err := ensurePrivate(dir); err != nil { | |||
| 491 | + | return nil, fmt.Errorf("vev: secure development temp directory: %w", err) | |||
| 492 | + | } | |||
| 493 | + | return daemon.WithTempDir(dir), nil | |||
| 494 | + | } | |||
| 495 | + | ||||
| 482 | func pprofAddrIsLoopback(addr string) bool { | 496 | func pprofAddrIsLoopback(addr string) bool { | ||
| 483 | host, _, err := net.SplitHostPort(addr) | 497 | host, _, err := net.SplitHostPort(addr) | ||
| 484 | if err != nil || host == "" { | 498 | if err != nil || host == "" { | ||
| @@ -640,6 +654,13 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err | |||||
| 640 | } | 654 | } | ||
| 641 | 655 | ||||
| 642 | daemonOpts := []daemon.Option{remoteDiscoveryOpt} | 656 | daemonOpts := []daemon.Option{remoteDiscoveryOpt} | ||
| 657 | + | developmentTempOpt, err := developmentTempDirOption(safedir.EnsurePrivate) | |||
| 658 | + | if err != nil { | |||
| 659 | + | return err | |||
| 660 | + | } | |||
| 661 | + | if developmentTempOpt != nil { | |||
| 662 | + | daemonOpts = append(daemonOpts, developmentTempOpt) | |||
| 663 | + | } | |||
| 643 | if observer != nil { | 664 | if observer != nil { | ||
| 644 | daemonOpts = append(daemonOpts, daemon.WithRuntimeObserver(observer)) | 665 | daemonOpts = append(daemonOpts, daemon.WithRuntimeObserver(observer)) | ||
| 645 | } | 666 | } | ||
M internal/app/run_test.go +79 -0
| @@ -424,6 +424,85 @@ func TestRunUDPProxyClientDeadAfterExceedsIdleTTL(t *testing.T) { | |||
| 424 | 424 | require.Greater(t, udpProxyClientTransportOptions.DeadAfter, udpProxyIdleTTL) | |
| 425 | 425 | } | |
| 426 | 426 | ||
| 427 | + | func TestDevelopmentTempDirOption(t *testing.T) { | |
| 428 | + | absoluteBase := filepath.Join(t.TempDir(), ".dev") | |
| 429 | + | creatorErr := errors.New("cannot secure directory") | |
| 430 | + | tests := []struct { | |
| 431 | + | name string | |
| 432 | + | environment string | |
| 433 | + | base string | |
| 434 | + | creatorErr error | |
| 435 | + | wantPath string | |
| 436 | + | wantOption bool | |
| 437 | + | wantErr error | |
| 438 | + | }{ | |
| 439 | + | { | |
| 440 | + | name: "inactive environment", | |
| 441 | + | base: absoluteBase, | |
| 442 | + | }, | |
| 443 | + | { | |
| 444 | + | name: "active environment", | |
| 445 | + | environment: "work", | |
| 446 | + | base: absoluteBase, | |
| 447 | + | wantPath: filepath.Join(absoluteBase, "work", "tmp"), | |
| 448 | + | wantOption: true, | |
| 449 | + | }, | |
| 450 | + | { | |
| 451 | + | name: "creator error", | |
| 452 | + | environment: "work", | |
| 453 | + | base: absoluteBase, | |
| 454 | + | creatorErr: creatorErr, | |
| 455 | + | wantPath: filepath.Join(absoluteBase, "work", "tmp"), | |
| 456 | + | wantErr: creatorErr, | |
| 457 | + | }, | |
| 458 | + | } | |
| 459 | + | ||
| 460 | + | for _, tt := range tests { | |
| 461 | + | t.Run(tt.name, func(t *testing.T) { | |
| 462 | + | t.Setenv("VEV_ENV", tt.environment) | |
| 463 | + | t.Setenv("VEV_ENV_ROOT", tt.base) | |
| 464 | + | var creatorCalls []string | |
| 465 | + | ||
| 466 | + | option, err := developmentTempDirOption(func(path string) error { | |
| 467 | + | creatorCalls = append(creatorCalls, path) | |
| 468 | + | return tt.creatorErr | |
| 469 | + | }) | |
| 470 | + | ||
| 471 | + | if tt.wantErr != nil { | |
| 472 | + | require.ErrorIs(t, err, tt.wantErr) | |
| 473 | + | require.Nil(t, option) | |
| 474 | + | } else { | |
| 475 | + | require.NoError(t, err) | |
| 476 | + | if tt.wantOption { | |
| 477 | + | require.NotNil(t, option) | |
| 478 | + | } else { | |
| 479 | + | require.Nil(t, option) | |
| 480 | + | } | |
| 481 | + | } | |
| 482 | + | if tt.wantPath == "" { | |
| 483 | + | require.Empty(t, creatorCalls) | |
| 484 | + | } else { | |
| 485 | + | require.Equal(t, []string{tt.wantPath}, creatorCalls) | |
| 486 | + | require.True(t, filepath.IsAbs(tt.wantPath)) | |
| 487 | + | } | |
| 488 | + | }) | |
| 489 | + | } | |
| 490 | + | } | |
| 491 | + | ||
| 492 | + | func TestRunActivatesDevelopmentEnvironmentBeforeParsing(t *testing.T) { | |
| 493 | + | t.Setenv("VEV_ENV", "../live") | |
| 494 | + | t.Setenv("XDG_CONFIG_HOME", "/existing/config") | |
| 495 | + | t.Setenv("XDG_STATE_HOME", "/existing/state") | |
| 496 | + | t.Setenv("XDG_RUNTIME_DIR", "/existing/runtime") | |
| 497 | + | ||
| 498 | + | err := Run([]string{"unknown"}) | |
| 499 | + | require.ErrorContains(t, err, "invalid VEV_ENV") | |
| 500 | + | require.NotContains(t, err.Error(), "unknown command") | |
| 501 | + | require.Equal(t, "/existing/config", os.Getenv("XDG_CONFIG_HOME")) | |
| 502 | + | require.Equal(t, "/existing/state", os.Getenv("XDG_STATE_HOME")) | |
| 503 | + | require.Equal(t, "/existing/runtime", os.Getenv("XDG_RUNTIME_DIR")) | |
| 504 | + | } | |
| 505 | + | ||
| 427 | 506 | func TestParseArgs(t *testing.T) { | |
| 428 | 507 | tests := []struct { | |
| 429 | 508 | name string | |
| @@ -424,6 +424,85 @@ func TestRunUDPProxyClientDeadAfterExceedsIdleTTL(t *testing.T) { | |||||
| 424 | require.Greater(t, udpProxyClientTransportOptions.DeadAfter, udpProxyIdleTTL) | 424 | require.Greater(t, udpProxyClientTransportOptions.DeadAfter, udpProxyIdleTTL) | ||
| 425 | } | 425 | } | ||
| 426 | 426 | ||||
| 427 | + | func TestDevelopmentTempDirOption(t *testing.T) { | |||
| 428 | + | absoluteBase := filepath.Join(t.TempDir(), ".dev") | |||
| 429 | + | creatorErr := errors.New("cannot secure directory") | |||
| 430 | + | tests := []struct { | |||
| 431 | + | name string | |||
| 432 | + | environment string | |||
| 433 | + | base string | |||
| 434 | + | creatorErr error | |||
| 435 | + | wantPath string | |||
| 436 | + | wantOption bool | |||
| 437 | + | wantErr error | |||
| 438 | + | }{ | |||
| 439 | + | { | |||
| 440 | + | name: "inactive environment", | |||
| 441 | + | base: absoluteBase, | |||
| 442 | + | }, | |||
| 443 | + | { | |||
| 444 | + | name: "active environment", | |||
| 445 | + | environment: "work", | |||
| 446 | + | base: absoluteBase, | |||
| 447 | + | wantPath: filepath.Join(absoluteBase, "work", "tmp"), | |||
| 448 | + | wantOption: true, | |||
| 449 | + | }, | |||
| 450 | + | { | |||
| 451 | + | name: "creator error", | |||
| 452 | + | environment: "work", | |||
| 453 | + | base: absoluteBase, | |||
| 454 | + | creatorErr: creatorErr, | |||
| 455 | + | wantPath: filepath.Join(absoluteBase, "work", "tmp"), | |||
| 456 | + | wantErr: creatorErr, | |||
| 457 | + | }, | |||
| 458 | + | } | |||
| 459 | + | ||||
| 460 | + | for _, tt := range tests { | |||
| 461 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 462 | + | t.Setenv("VEV_ENV", tt.environment) | |||
| 463 | + | t.Setenv("VEV_ENV_ROOT", tt.base) | |||
| 464 | + | var creatorCalls []string | |||
| 465 | + | ||||
| 466 | + | option, err := developmentTempDirOption(func(path string) error { | |||
| 467 | + | creatorCalls = append(creatorCalls, path) | |||
| 468 | + | return tt.creatorErr | |||
| 469 | + | }) | |||
| 470 | + | ||||
| 471 | + | if tt.wantErr != nil { | |||
| 472 | + | require.ErrorIs(t, err, tt.wantErr) | |||
| 473 | + | require.Nil(t, option) | |||
| 474 | + | } else { | |||
| 475 | + | require.NoError(t, err) | |||
| 476 | + | if tt.wantOption { | |||
| 477 | + | require.NotNil(t, option) | |||
| 478 | + | } else { | |||
| 479 | + | require.Nil(t, option) | |||
| 480 | + | } | |||
| 481 | + | } | |||
| 482 | + | if tt.wantPath == "" { | |||
| 483 | + | require.Empty(t, creatorCalls) | |||
| 484 | + | } else { | |||
| 485 | + | require.Equal(t, []string{tt.wantPath}, creatorCalls) | |||
| 486 | + | require.True(t, filepath.IsAbs(tt.wantPath)) | |||
| 487 | + | } | |||
| 488 | + | }) | |||
| 489 | + | } | |||
| 490 | + | } | |||
| 491 | + | ||||
| 492 | + | func TestRunActivatesDevelopmentEnvironmentBeforeParsing(t *testing.T) { | |||
| 493 | + | t.Setenv("VEV_ENV", "../live") | |||
| 494 | + | t.Setenv("XDG_CONFIG_HOME", "/existing/config") | |||
| 495 | + | t.Setenv("XDG_STATE_HOME", "/existing/state") | |||
| 496 | + | t.Setenv("XDG_RUNTIME_DIR", "/existing/runtime") | |||
| 497 | + | ||||
| 498 | + | err := Run([]string{"unknown"}) | |||
| 499 | + | require.ErrorContains(t, err, "invalid VEV_ENV") | |||
| 500 | + | require.NotContains(t, err.Error(), "unknown command") | |||
| 501 | + | require.Equal(t, "/existing/config", os.Getenv("XDG_CONFIG_HOME")) | |||
| 502 | + | require.Equal(t, "/existing/state", os.Getenv("XDG_STATE_HOME")) | |||
| 503 | + | require.Equal(t, "/existing/runtime", os.Getenv("XDG_RUNTIME_DIR")) | |||
| 504 | + | } | |||
| 505 | + | ||||
| 427 | func TestParseArgs(t *testing.T) { | 506 | func TestParseArgs(t *testing.T) { | ||
| 428 | tests := []struct { | 507 | tests := []struct { | ||
| 429 | name string | 508 | name string | ||
M internal/platform/dir.go +84 -0
| @@ -6,6 +6,90 @@ import ( | |||
| 6 | 6 | "path/filepath" | |
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | + | const devEnvironmentRootEnv = "VEV_ENV_ROOT" | |
| 10 | + | ||
| 11 | + | // ActivateDevelopmentEnvironment redirects vev's XDG roots into an isolated | |
| 12 | + | // development tree when VEV_ENV is set. The absolute .dev base is retained in | |
| 13 | + | // VEV_ENV_ROOT so re-executed roles do not rebase after changing directory. | |
| 14 | + | func ActivateDevelopmentEnvironment() error { | |
| 15 | + | name := os.Getenv("VEV_ENV") | |
| 16 | + | if name == "" { | |
| 17 | + | return nil | |
| 18 | + | } | |
| 19 | + | if !validDevelopmentEnvironmentName(name) { | |
| 20 | + | return fmt.Errorf("vev: invalid VEV_ENV %q (want one safe segment matching [A-Za-z0-9][A-Za-z0-9._-]{0,63})", name) | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | base := os.Getenv(devEnvironmentRootEnv) | |
| 24 | + | if base == "" { | |
| 25 | + | absolute, err := filepath.Abs(".dev") | |
| 26 | + | if err != nil { | |
| 27 | + | return fmt.Errorf("vev: resolve VEV_ENV %q root: %w", name, err) | |
| 28 | + | } | |
| 29 | + | base = absolute | |
| 30 | + | } else if !filepath.IsAbs(base) { | |
| 31 | + | return fmt.Errorf("vev: invalid %s %q (want an absolute path)", devEnvironmentRootEnv, base) | |
| 32 | + | } | |
| 33 | + | root := filepath.Join(base, name) | |
| 34 | + | ||
| 35 | + | values := map[string]string{ | |
| 36 | + | devEnvironmentRootEnv: base, | |
| 37 | + | "XDG_CONFIG_HOME": filepath.Join(root, "config"), | |
| 38 | + | "XDG_STATE_HOME": filepath.Join(root, "state"), | |
| 39 | + | "XDG_RUNTIME_DIR": filepath.Join(root, "runtime"), | |
| 40 | + | } | |
| 41 | + | type previousValue struct { | |
| 42 | + | value string | |
| 43 | + | set bool | |
| 44 | + | } | |
| 45 | + | previous := make(map[string]previousValue, len(values)) | |
| 46 | + | for key := range values { | |
| 47 | + | value, set := os.LookupEnv(key) | |
| 48 | + | previous[key] = previousValue{value: value, set: set} | |
| 49 | + | } | |
| 50 | + | for key, value := range values { | |
| 51 | + | if err := os.Setenv(key, value); err != nil { | |
| 52 | + | for rollbackKey, old := range previous { | |
| 53 | + | if old.set { | |
| 54 | + | _ = os.Setenv(rollbackKey, old.value) | |
| 55 | + | } else { | |
| 56 | + | _ = os.Unsetenv(rollbackKey) | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | return fmt.Errorf("vev: activate VEV_ENV %q: %w", name, err) | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | return nil | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func validDevelopmentEnvironmentName(name string) bool { | |
| 66 | + | if len(name) == 0 || len(name) > 64 || !asciiAlphaNumeric(name[0]) { | |
| 67 | + | return false | |
| 68 | + | } | |
| 69 | + | for i := 1; i < len(name); i++ { | |
| 70 | + | if !asciiAlphaNumeric(name[i]) && name[i] != '.' && name[i] != '_' && name[i] != '-' { | |
| 71 | + | return false | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | return true | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func asciiAlphaNumeric(value byte) bool { | |
| 78 | + | return value >= 'A' && value <= 'Z' || value >= 'a' && value <= 'z' || value >= '0' && value <= '9' | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // DevelopmentEnvironmentTempDir returns the private temporary-file directory | |
| 82 | + | // for an activated development environment. Ordinary invocations use the | |
| 83 | + | // existing os.TempDir fallback in the daemon. | |
| 84 | + | func DevelopmentEnvironmentTempDir() (string, bool) { | |
| 85 | + | name := os.Getenv("VEV_ENV") | |
| 86 | + | base := os.Getenv(devEnvironmentRootEnv) | |
| 87 | + | if name == "" || base == "" { | |
| 88 | + | return "", false | |
| 89 | + | } | |
| 90 | + | return filepath.Join(base, name, "tmp"), true | |
| 91 | + | } | |
| 92 | + | ||
| 9 | 93 | // StateDir returns the directory vev writes state to: $XDG_STATE_HOME/vev if | |
| 10 | 94 | // set, else ~/.local/state/vev. | |
| 11 | 95 | func StateDir() string { | |
| @@ -6,6 +6,90 @@ import ( | |||||
| 6 | "path/filepath" | 6 | "path/filepath" | ||
| 7 | ) | 7 | ) | ||
| 8 | 8 | ||||
| 9 | + | const devEnvironmentRootEnv = "VEV_ENV_ROOT" | |||
| 10 | + | ||||
| 11 | + | // ActivateDevelopmentEnvironment redirects vev's XDG roots into an isolated | |||
| 12 | + | // development tree when VEV_ENV is set. The absolute .dev base is retained in | |||
| 13 | + | // VEV_ENV_ROOT so re-executed roles do not rebase after changing directory. | |||
| 14 | + | func ActivateDevelopmentEnvironment() error { | |||
| 15 | + | name := os.Getenv("VEV_ENV") | |||
| 16 | + | if name == "" { | |||
| 17 | + | return nil | |||
| 18 | + | } | |||
| 19 | + | if !validDevelopmentEnvironmentName(name) { | |||
| 20 | + | return fmt.Errorf("vev: invalid VEV_ENV %q (want one safe segment matching [A-Za-z0-9][A-Za-z0-9._-]{0,63})", name) | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | base := os.Getenv(devEnvironmentRootEnv) | |||
| 24 | + | if base == "" { | |||
| 25 | + | absolute, err := filepath.Abs(".dev") | |||
| 26 | + | if err != nil { | |||
| 27 | + | return fmt.Errorf("vev: resolve VEV_ENV %q root: %w", name, err) | |||
| 28 | + | } | |||
| 29 | + | base = absolute | |||
| 30 | + | } else if !filepath.IsAbs(base) { | |||
| 31 | + | return fmt.Errorf("vev: invalid %s %q (want an absolute path)", devEnvironmentRootEnv, base) | |||
| 32 | + | } | |||
| 33 | + | root := filepath.Join(base, name) | |||
| 34 | + | ||||
| 35 | + | values := map[string]string{ | |||
| 36 | + | devEnvironmentRootEnv: base, | |||
| 37 | + | "XDG_CONFIG_HOME": filepath.Join(root, "config"), | |||
| 38 | + | "XDG_STATE_HOME": filepath.Join(root, "state"), | |||
| 39 | + | "XDG_RUNTIME_DIR": filepath.Join(root, "runtime"), | |||
| 40 | + | } | |||
| 41 | + | type previousValue struct { | |||
| 42 | + | value string | |||
| 43 | + | set bool | |||
| 44 | + | } | |||
| 45 | + | previous := make(map[string]previousValue, len(values)) | |||
| 46 | + | for key := range values { | |||
| 47 | + | value, set := os.LookupEnv(key) | |||
| 48 | + | previous[key] = previousValue{value: value, set: set} | |||
| 49 | + | } | |||
| 50 | + | for key, value := range values { | |||
| 51 | + | if err := os.Setenv(key, value); err != nil { | |||
| 52 | + | for rollbackKey, old := range previous { | |||
| 53 | + | if old.set { | |||
| 54 | + | _ = os.Setenv(rollbackKey, old.value) | |||
| 55 | + | } else { | |||
| 56 | + | _ = os.Unsetenv(rollbackKey) | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | return fmt.Errorf("vev: activate VEV_ENV %q: %w", name, err) | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | return nil | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func validDevelopmentEnvironmentName(name string) bool { | |||
| 66 | + | if len(name) == 0 || len(name) > 64 || !asciiAlphaNumeric(name[0]) { | |||
| 67 | + | return false | |||
| 68 | + | } | |||
| 69 | + | for i := 1; i < len(name); i++ { | |||
| 70 | + | if !asciiAlphaNumeric(name[i]) && name[i] != '.' && name[i] != '_' && name[i] != '-' { | |||
| 71 | + | return false | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | return true | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func asciiAlphaNumeric(value byte) bool { | |||
| 78 | + | return value >= 'A' && value <= 'Z' || value >= 'a' && value <= 'z' || value >= '0' && value <= '9' | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // DevelopmentEnvironmentTempDir returns the private temporary-file directory | |||
| 82 | + | // for an activated development environment. Ordinary invocations use the | |||
| 83 | + | // existing os.TempDir fallback in the daemon. | |||
| 84 | + | func DevelopmentEnvironmentTempDir() (string, bool) { | |||
| 85 | + | name := os.Getenv("VEV_ENV") | |||
| 86 | + | base := os.Getenv(devEnvironmentRootEnv) | |||
| 87 | + | if name == "" || base == "" { | |||
| 88 | + | return "", false | |||
| 89 | + | } | |||
| 90 | + | return filepath.Join(base, name, "tmp"), true | |||
| 91 | + | } | |||
| 92 | + | ||||
| 9 | // StateDir returns the directory vev writes state to: $XDG_STATE_HOME/vev if | 93 | // StateDir returns the directory vev writes state to: $XDG_STATE_HOME/vev if | ||
| 10 | // set, else ~/.local/state/vev. | 94 | // set, else ~/.local/state/vev. | ||
| 11 | func StateDir() string { | 95 | func StateDir() string { | ||
M internal/platform/dir_test.go +217 -0
| @@ -7,6 +7,223 @@ import ( | |||
| 7 | 7 | "testing" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | + | func TestActivateDevelopmentEnvironment(t *testing.T) { | |
| 11 | + | originalCwd, err := os.Getwd() | |
| 12 | + | if err != nil { | |
| 13 | + | t.Fatal(err) | |
| 14 | + | } | |
| 15 | + | t.Cleanup(func() { | |
| 16 | + | if err := os.Chdir(originalCwd); err != nil { | |
| 17 | + | t.Errorf("restore working directory: %v", err) | |
| 18 | + | } | |
| 19 | + | }) | |
| 20 | + | ||
| 21 | + | distinctCwd := t.TempDir() | |
| 22 | + | tests := []struct { | |
| 23 | + | name string | |
| 24 | + | environment string | |
| 25 | + | cwd string | |
| 26 | + | wantErr bool | |
| 27 | + | check func(*testing.T, string) | |
| 28 | + | }{ | |
| 29 | + | { | |
| 30 | + | name: "unset preserves existing XDG roots", | |
| 31 | + | check: func(t *testing.T, _ string) { | |
| 32 | + | t.Helper() | |
| 33 | + | if got := os.Getenv("XDG_CONFIG_HOME"); got != "/existing/config" { | |
| 34 | + | t.Errorf("XDG_CONFIG_HOME = %q", got) | |
| 35 | + | } | |
| 36 | + | if got := os.Getenv("XDG_STATE_HOME"); got != "/existing/state" { | |
| 37 | + | t.Errorf("XDG_STATE_HOME = %q", got) | |
| 38 | + | } | |
| 39 | + | if got := os.Getenv("XDG_RUNTIME_DIR"); got != "/existing/runtime" { | |
| 40 | + | t.Errorf("XDG_RUNTIME_DIR = %q", got) | |
| 41 | + | } | |
| 42 | + | }, | |
| 43 | + | }, | |
| 44 | + | { | |
| 45 | + | name: "valid name overrides XDG roots", | |
| 46 | + | environment: "dev", | |
| 47 | + | check: func(t *testing.T, cwd string) { | |
| 48 | + | t.Helper() | |
| 49 | + | root := filepath.Join(cwd, ".dev", "dev") | |
| 50 | + | if got := os.Getenv("XDG_CONFIG_HOME"); got != filepath.Join(root, "config") { | |
| 51 | + | t.Errorf("XDG_CONFIG_HOME = %q", got) | |
| 52 | + | } | |
| 53 | + | if got := os.Getenv("XDG_STATE_HOME"); got != filepath.Join(root, "state") { | |
| 54 | + | t.Errorf("XDG_STATE_HOME = %q", got) | |
| 55 | + | } | |
| 56 | + | if got := os.Getenv("XDG_RUNTIME_DIR"); got != filepath.Join(root, "runtime") { | |
| 57 | + | t.Errorf("XDG_RUNTIME_DIR = %q", got) | |
| 58 | + | } | |
| 59 | + | if got, want := os.Getenv("VEV_ENV_ROOT"), filepath.Join(cwd, ".dev"); got != want { | |
| 60 | + | t.Errorf("VEV_ENV_ROOT = %q, want %q", got, want) | |
| 61 | + | } | |
| 62 | + | if _, err := os.Stat(root); !os.IsNotExist(err) { | |
| 63 | + | t.Errorf("activation created root or returned unexpected error: %v", err) | |
| 64 | + | } | |
| 65 | + | }, | |
| 66 | + | }, | |
| 67 | + | { | |
| 68 | + | name: "resolved root survives cwd change", | |
| 69 | + | environment: "persisted", | |
| 70 | + | check: func(t *testing.T, cwd string) { | |
| 71 | + | t.Helper() | |
| 72 | + | root := filepath.Join(cwd, ".dev", "persisted") | |
| 73 | + | other := t.TempDir() | |
| 74 | + | if err := os.Chdir(other); err != nil { | |
| 75 | + | t.Fatal(err) | |
| 76 | + | } | |
| 77 | + | if err := ActivateDevelopmentEnvironment(); err != nil { | |
| 78 | + | t.Fatal(err) | |
| 79 | + | } | |
| 80 | + | if got := os.Getenv("XDG_STATE_HOME"); got != filepath.Join(root, "state") { | |
| 81 | + | t.Errorf("XDG_STATE_HOME after cwd change = %q", got) | |
| 82 | + | } | |
| 83 | + | }, | |
| 84 | + | }, | |
| 85 | + | {name: "distinct name alpha", environment: "alpha", cwd: distinctCwd}, | |
| 86 | + | {name: "distinct name beta", environment: "beta", cwd: distinctCwd}, | |
| 87 | + | {name: "reject traversal", environment: "../live", wantErr: true}, | |
| 88 | + | {name: "reject separator", environment: "a/b", wantErr: true}, | |
| 89 | + | {name: "reject whitespace", environment: "two words", wantErr: true}, | |
| 90 | + | {name: "reject leading punctuation", environment: ".hidden", wantErr: true}, | |
| 91 | + | {name: "reject 65 characters", environment: strings.Repeat("a", 65), wantErr: true}, | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | for _, tt := range tests { | |
| 95 | + | t.Run(tt.name, func(t *testing.T) { | |
| 96 | + | cwd := tt.cwd | |
| 97 | + | if cwd == "" { | |
| 98 | + | cwd = t.TempDir() | |
| 99 | + | } | |
| 100 | + | if err := os.Chdir(cwd); err != nil { | |
| 101 | + | t.Fatal(err) | |
| 102 | + | } | |
| 103 | + | canonicalCwd, getwdErr := os.Getwd() | |
| 104 | + | if getwdErr != nil { | |
| 105 | + | t.Fatal(getwdErr) | |
| 106 | + | } | |
| 107 | + | t.Setenv("VEV_ENV", tt.environment) | |
| 108 | + | t.Setenv("VEV_ENV_ROOT", "") | |
| 109 | + | t.Setenv("XDG_CONFIG_HOME", "/existing/config") | |
| 110 | + | t.Setenv("XDG_STATE_HOME", "/existing/state") | |
| 111 | + | t.Setenv("XDG_RUNTIME_DIR", "/existing/runtime") | |
| 112 | + | ||
| 113 | + | err := ActivateDevelopmentEnvironment() | |
| 114 | + | if tt.wantErr { | |
| 115 | + | if err == nil || !strings.Contains(err.Error(), "VEV_ENV") { | |
| 116 | + | t.Fatalf("ActivateDevelopmentEnvironment() error = %v", err) | |
| 117 | + | } | |
| 118 | + | for name, want := range map[string]string{ | |
| 119 | + | "XDG_CONFIG_HOME": "/existing/config", | |
| 120 | + | "XDG_STATE_HOME": "/existing/state", | |
| 121 | + | "XDG_RUNTIME_DIR": "/existing/runtime", | |
| 122 | + | "VEV_ENV_ROOT": "", | |
| 123 | + | } { | |
| 124 | + | if got := os.Getenv(name); got != want { | |
| 125 | + | t.Errorf("%s changed to %q, want %q", name, got, want) | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | return | |
| 129 | + | } | |
| 130 | + | if err != nil { | |
| 131 | + | t.Fatal(err) | |
| 132 | + | } | |
| 133 | + | if tt.check != nil { | |
| 134 | + | tt.check(t, canonicalCwd) | |
| 135 | + | } | |
| 136 | + | }) | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func TestDevelopmentEnvironmentTempDir(t *testing.T) { | |
| 141 | + | absoluteBase := filepath.Join(t.TempDir(), ".dev") | |
| 142 | + | tests := []struct { | |
| 143 | + | name string | |
| 144 | + | environment string | |
| 145 | + | base string | |
| 146 | + | want string | |
| 147 | + | wantActive bool | |
| 148 | + | }{ | |
| 149 | + | {name: "inactive without environment", base: absoluteBase}, | |
| 150 | + | {name: "inactive without inherited base", environment: "work"}, | |
| 151 | + | { | |
| 152 | + | name: "active environment", | |
| 153 | + | environment: "work", | |
| 154 | + | base: absoluteBase, | |
| 155 | + | want: filepath.Join(absoluteBase, "work", "tmp"), | |
| 156 | + | wantActive: true, | |
| 157 | + | }, | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | for _, tt := range tests { | |
| 161 | + | t.Run(tt.name, func(t *testing.T) { | |
| 162 | + | t.Setenv("VEV_ENV", tt.environment) | |
| 163 | + | t.Setenv("VEV_ENV_ROOT", tt.base) | |
| 164 | + | ||
| 165 | + | got, active := DevelopmentEnvironmentTempDir() | |
| 166 | + | if got != tt.want || active != tt.wantActive { | |
| 167 | + | t.Fatalf("DevelopmentEnvironmentTempDir() = (%q, %v), want (%q, %v)", got, active, tt.want, tt.wantActive) | |
| 168 | + | } | |
| 169 | + | if active && !filepath.IsAbs(got) { | |
| 170 | + | t.Errorf("DevelopmentEnvironmentTempDir() = %q, want absolute path", got) | |
| 171 | + | } | |
| 172 | + | }) | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func TestActivateDevelopmentEnvironmentRecomputesProfileFromInheritedBase(t *testing.T) { | |
| 177 | + | originalCwd, err := os.Getwd() | |
| 178 | + | if err != nil { | |
| 179 | + | t.Fatal(err) | |
| 180 | + | } | |
| 181 | + | t.Cleanup(func() { | |
| 182 | + | if err := os.Chdir(originalCwd); err != nil { | |
| 183 | + | t.Errorf("restore working directory: %v", err) | |
| 184 | + | } | |
| 185 | + | }) | |
| 186 | + | ||
| 187 | + | if err := os.Chdir(t.TempDir()); err != nil { | |
| 188 | + | t.Fatal(err) | |
| 189 | + | } | |
| 190 | + | invocationCwd, err := os.Getwd() | |
| 191 | + | if err != nil { | |
| 192 | + | t.Fatal(err) | |
| 193 | + | } | |
| 194 | + | t.Setenv("VEV_ENV", "alpha") | |
| 195 | + | t.Setenv("VEV_ENV_ROOT", "") | |
| 196 | + | if err := ActivateDevelopmentEnvironment(); err != nil { | |
| 197 | + | t.Fatal(err) | |
| 198 | + | } | |
| 199 | + | base := filepath.Join(invocationCwd, ".dev") | |
| 200 | + | if got := os.Getenv("VEV_ENV_ROOT"); got != base { | |
| 201 | + | t.Fatalf("VEV_ENV_ROOT = %q, want %q", got, base) | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | if err := os.Chdir(t.TempDir()); err != nil { | |
| 205 | + | t.Fatal(err) | |
| 206 | + | } | |
| 207 | + | if err := os.Setenv("VEV_ENV", "beta"); err != nil { | |
| 208 | + | t.Fatal(err) | |
| 209 | + | } | |
| 210 | + | if err := ActivateDevelopmentEnvironment(); err != nil { | |
| 211 | + | t.Fatal(err) | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | betaRoot := filepath.Join(base, "beta") | |
| 215 | + | for name, want := range map[string]string{ | |
| 216 | + | "VEV_ENV_ROOT": base, | |
| 217 | + | "XDG_CONFIG_HOME": filepath.Join(betaRoot, "config"), | |
| 218 | + | "XDG_STATE_HOME": filepath.Join(betaRoot, "state"), | |
| 219 | + | "XDG_RUNTIME_DIR": filepath.Join(betaRoot, "runtime"), | |
| 220 | + | } { | |
| 221 | + | if got := os.Getenv(name); got != want { | |
| 222 | + | t.Errorf("%s = %q, want %q", name, got, want) | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | ||
| 10 | 227 | func TestStateDir(t *testing.T) { | |
| 11 | 228 | tests := []struct { | |
| 12 | 229 | name string | |
| @@ -7,6 +7,223 @@ import ( | |||||
| 7 | "testing" | 7 | "testing" | ||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | + | func TestActivateDevelopmentEnvironment(t *testing.T) { | |||
| 11 | + | originalCwd, err := os.Getwd() | |||
| 12 | + | if err != nil { | |||
| 13 | + | t.Fatal(err) | |||
| 14 | + | } | |||
| 15 | + | t.Cleanup(func() { | |||
| 16 | + | if err := os.Chdir(originalCwd); err != nil { | |||
| 17 | + | t.Errorf("restore working directory: %v", err) | |||
| 18 | + | } | |||
| 19 | + | }) | |||
| 20 | + | ||||
| 21 | + | distinctCwd := t.TempDir() | |||
| 22 | + | tests := []struct { | |||
| 23 | + | name string | |||
| 24 | + | environment string | |||
| 25 | + | cwd string | |||
| 26 | + | wantErr bool | |||
| 27 | + | check func(*testing.T, string) | |||
| 28 | + | }{ | |||
| 29 | + | { | |||
| 30 | + | name: "unset preserves existing XDG roots", | |||
| 31 | + | check: func(t *testing.T, _ string) { | |||
| 32 | + | t.Helper() | |||
| 33 | + | if got := os.Getenv("XDG_CONFIG_HOME"); got != "/existing/config" { | |||
| 34 | + | t.Errorf("XDG_CONFIG_HOME = %q", got) | |||
| 35 | + | } | |||
| 36 | + | if got := os.Getenv("XDG_STATE_HOME"); got != "/existing/state" { | |||
| 37 | + | t.Errorf("XDG_STATE_HOME = %q", got) | |||
| 38 | + | } | |||
| 39 | + | if got := os.Getenv("XDG_RUNTIME_DIR"); got != "/existing/runtime" { | |||
| 40 | + | t.Errorf("XDG_RUNTIME_DIR = %q", got) | |||
| 41 | + | } | |||
| 42 | + | }, | |||
| 43 | + | }, | |||
| 44 | + | { | |||
| 45 | + | name: "valid name overrides XDG roots", | |||
| 46 | + | environment: "dev", | |||
| 47 | + | check: func(t *testing.T, cwd string) { | |||
| 48 | + | t.Helper() | |||
| 49 | + | root := filepath.Join(cwd, ".dev", "dev") | |||
| 50 | + | if got := os.Getenv("XDG_CONFIG_HOME"); got != filepath.Join(root, "config") { | |||
| 51 | + | t.Errorf("XDG_CONFIG_HOME = %q", got) | |||
| 52 | + | } | |||
| 53 | + | if got := os.Getenv("XDG_STATE_HOME"); got != filepath.Join(root, "state") { | |||
| 54 | + | t.Errorf("XDG_STATE_HOME = %q", got) | |||
| 55 | + | } | |||
| 56 | + | if got := os.Getenv("XDG_RUNTIME_DIR"); got != filepath.Join(root, "runtime") { | |||
| 57 | + | t.Errorf("XDG_RUNTIME_DIR = %q", got) | |||
| 58 | + | } | |||
| 59 | + | if got, want := os.Getenv("VEV_ENV_ROOT"), filepath.Join(cwd, ".dev"); got != want { | |||
| 60 | + | t.Errorf("VEV_ENV_ROOT = %q, want %q", got, want) | |||
| 61 | + | } | |||
| 62 | + | if _, err := os.Stat(root); !os.IsNotExist(err) { | |||
| 63 | + | t.Errorf("activation created root or returned unexpected error: %v", err) | |||
| 64 | + | } | |||
| 65 | + | }, | |||
| 66 | + | }, | |||
| 67 | + | { | |||
| 68 | + | name: "resolved root survives cwd change", | |||
| 69 | + | environment: "persisted", | |||
| 70 | + | check: func(t *testing.T, cwd string) { | |||
| 71 | + | t.Helper() | |||
| 72 | + | root := filepath.Join(cwd, ".dev", "persisted") | |||
| 73 | + | other := t.TempDir() | |||
| 74 | + | if err := os.Chdir(other); err != nil { | |||
| 75 | + | t.Fatal(err) | |||
| 76 | + | } | |||
| 77 | + | if err := ActivateDevelopmentEnvironment(); err != nil { | |||
| 78 | + | t.Fatal(err) | |||
| 79 | + | } | |||
| 80 | + | if got := os.Getenv("XDG_STATE_HOME"); got != filepath.Join(root, "state") { | |||
| 81 | + | t.Errorf("XDG_STATE_HOME after cwd change = %q", got) | |||
| 82 | + | } | |||
| 83 | + | }, | |||
| 84 | + | }, | |||
| 85 | + | {name: "distinct name alpha", environment: "alpha", cwd: distinctCwd}, | |||
| 86 | + | {name: "distinct name beta", environment: "beta", cwd: distinctCwd}, | |||
| 87 | + | {name: "reject traversal", environment: "../live", wantErr: true}, | |||
| 88 | + | {name: "reject separator", environment: "a/b", wantErr: true}, | |||
| 89 | + | {name: "reject whitespace", environment: "two words", wantErr: true}, | |||
| 90 | + | {name: "reject leading punctuation", environment: ".hidden", wantErr: true}, | |||
| 91 | + | {name: "reject 65 characters", environment: strings.Repeat("a", 65), wantErr: true}, | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | for _, tt := range tests { | |||
| 95 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 96 | + | cwd := tt.cwd | |||
| 97 | + | if cwd == "" { | |||
| 98 | + | cwd = t.TempDir() | |||
| 99 | + | } | |||
| 100 | + | if err := os.Chdir(cwd); err != nil { | |||
| 101 | + | t.Fatal(err) | |||
| 102 | + | } | |||
| 103 | + | canonicalCwd, getwdErr := os.Getwd() | |||
| 104 | + | if getwdErr != nil { | |||
| 105 | + | t.Fatal(getwdErr) | |||
| 106 | + | } | |||
| 107 | + | t.Setenv("VEV_ENV", tt.environment) | |||
| 108 | + | t.Setenv("VEV_ENV_ROOT", "") | |||
| 109 | + | t.Setenv("XDG_CONFIG_HOME", "/existing/config") | |||
| 110 | + | t.Setenv("XDG_STATE_HOME", "/existing/state") | |||
| 111 | + | t.Setenv("XDG_RUNTIME_DIR", "/existing/runtime") | |||
| 112 | + | ||||
| 113 | + | err := ActivateDevelopmentEnvironment() | |||
| 114 | + | if tt.wantErr { | |||
| 115 | + | if err == nil || !strings.Contains(err.Error(), "VEV_ENV") { | |||
| 116 | + | t.Fatalf("ActivateDevelopmentEnvironment() error = %v", err) | |||
| 117 | + | } | |||
| 118 | + | for name, want := range map[string]string{ | |||
| 119 | + | "XDG_CONFIG_HOME": "/existing/config", | |||
| 120 | + | "XDG_STATE_HOME": "/existing/state", | |||
| 121 | + | "XDG_RUNTIME_DIR": "/existing/runtime", | |||
| 122 | + | "VEV_ENV_ROOT": "", | |||
| 123 | + | } { | |||
| 124 | + | if got := os.Getenv(name); got != want { | |||
| 125 | + | t.Errorf("%s changed to %q, want %q", name, got, want) | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | return | |||
| 129 | + | } | |||
| 130 | + | if err != nil { | |||
| 131 | + | t.Fatal(err) | |||
| 132 | + | } | |||
| 133 | + | if tt.check != nil { | |||
| 134 | + | tt.check(t, canonicalCwd) | |||
| 135 | + | } | |||
| 136 | + | }) | |||
| 137 | + | } | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | func TestDevelopmentEnvironmentTempDir(t *testing.T) { | |||
| 141 | + | absoluteBase := filepath.Join(t.TempDir(), ".dev") | |||
| 142 | + | tests := []struct { | |||
| 143 | + | name string | |||
| 144 | + | environment string | |||
| 145 | + | base string | |||
| 146 | + | want string | |||
| 147 | + | wantActive bool | |||
| 148 | + | }{ | |||
| 149 | + | {name: "inactive without environment", base: absoluteBase}, | |||
| 150 | + | {name: "inactive without inherited base", environment: "work"}, | |||
| 151 | + | { | |||
| 152 | + | name: "active environment", | |||
| 153 | + | environment: "work", | |||
| 154 | + | base: absoluteBase, | |||
| 155 | + | want: filepath.Join(absoluteBase, "work", "tmp"), | |||
| 156 | + | wantActive: true, | |||
| 157 | + | }, | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | for _, tt := range tests { | |||
| 161 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 162 | + | t.Setenv("VEV_ENV", tt.environment) | |||
| 163 | + | t.Setenv("VEV_ENV_ROOT", tt.base) | |||
| 164 | + | ||||
| 165 | + | got, active := DevelopmentEnvironmentTempDir() | |||
| 166 | + | if got != tt.want || active != tt.wantActive { | |||
| 167 | + | t.Fatalf("DevelopmentEnvironmentTempDir() = (%q, %v), want (%q, %v)", got, active, tt.want, tt.wantActive) | |||
| 168 | + | } | |||
| 169 | + | if active && !filepath.IsAbs(got) { | |||
| 170 | + | t.Errorf("DevelopmentEnvironmentTempDir() = %q, want absolute path", got) | |||
| 171 | + | } | |||
| 172 | + | }) | |||
| 173 | + | } | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | func TestActivateDevelopmentEnvironmentRecomputesProfileFromInheritedBase(t *testing.T) { | |||
| 177 | + | originalCwd, err := os.Getwd() | |||
| 178 | + | if err != nil { | |||
| 179 | + | t.Fatal(err) | |||
| 180 | + | } | |||
| 181 | + | t.Cleanup(func() { | |||
| 182 | + | if err := os.Chdir(originalCwd); err != nil { | |||
| 183 | + | t.Errorf("restore working directory: %v", err) | |||
| 184 | + | } | |||
| 185 | + | }) | |||
| 186 | + | ||||
| 187 | + | if err := os.Chdir(t.TempDir()); err != nil { | |||
| 188 | + | t.Fatal(err) | |||
| 189 | + | } | |||
| 190 | + | invocationCwd, err := os.Getwd() | |||
| 191 | + | if err != nil { | |||
| 192 | + | t.Fatal(err) | |||
| 193 | + | } | |||
| 194 | + | t.Setenv("VEV_ENV", "alpha") | |||
| 195 | + | t.Setenv("VEV_ENV_ROOT", "") | |||
| 196 | + | if err := ActivateDevelopmentEnvironment(); err != nil { | |||
| 197 | + | t.Fatal(err) | |||
| 198 | + | } | |||
| 199 | + | base := filepath.Join(invocationCwd, ".dev") | |||
| 200 | + | if got := os.Getenv("VEV_ENV_ROOT"); got != base { | |||
| 201 | + | t.Fatalf("VEV_ENV_ROOT = %q, want %q", got, base) | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | if err := os.Chdir(t.TempDir()); err != nil { | |||
| 205 | + | t.Fatal(err) | |||
| 206 | + | } | |||
| 207 | + | if err := os.Setenv("VEV_ENV", "beta"); err != nil { | |||
| 208 | + | t.Fatal(err) | |||
| 209 | + | } | |||
| 210 | + | if err := ActivateDevelopmentEnvironment(); err != nil { | |||
| 211 | + | t.Fatal(err) | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | betaRoot := filepath.Join(base, "beta") | |||
| 215 | + | for name, want := range map[string]string{ | |||
| 216 | + | "VEV_ENV_ROOT": base, | |||
| 217 | + | "XDG_CONFIG_HOME": filepath.Join(betaRoot, "config"), | |||
| 218 | + | "XDG_STATE_HOME": filepath.Join(betaRoot, "state"), | |||
| 219 | + | "XDG_RUNTIME_DIR": filepath.Join(betaRoot, "runtime"), | |||
| 220 | + | } { | |||
| 221 | + | if got := os.Getenv(name); got != want { | |||
| 222 | + | t.Errorf("%s = %q, want %q", name, got, want) | |||
| 223 | + | } | |||
| 224 | + | } | |||
| 225 | + | } | |||
| 226 | + | ||||
| 10 | func TestStateDir(t *testing.T) { | 227 | func TestStateDir(t *testing.T) { | ||
| 11 | tests := []struct { | 228 | tests := []struct { | ||
| 12 | name string | 229 | name string | ||