Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changes/v1.16/BUG FIXES-20260515-135704.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
kind: BUG FIXES
body: 'workspace: Terraform will now error if an invalid workspace name becomes selected due to actions performed out-of-band'
time: 2026-05-15T13:57:04.484645+01:00
custom:
Issue: "38594"
3 changes: 2 additions & 1 deletion internal/command/cloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,8 @@ func (c *CloudCommand) discoverAndConfigure() tfdiags.Diagnostics {

currentWorkspace, err := c.Workspace()
if err != nil {
// The only possible error here is "you set TF_WORKSPACE badly"
// The only possible errors here are "you set TF_WORKSPACE badly",
// or "someone other than Terraform CLI tampered with your .terraform/environment file".
return diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Bad current workspace",
Expand Down
34 changes: 27 additions & 7 deletions internal/command/meta.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,10 @@ type Meta struct {
// Override certain behavior for tests within this package
testingOverrides *testingOverrides

// Overrides checking the validity of the workspace name set in .terraform/environment (not ENVs).
// This is to enable commands used to recover from invalid workspaces to run without the error blocking them.
bypassWorkspaceNameValidityCheck bool

//----------------------------------------------------------
// Private: do not set these
//----------------------------------------------------------
Expand Down Expand Up @@ -764,34 +768,50 @@ var errInvalidWorkspaceNameEnvVar = fmt.Errorf("Invalid workspace name set using

// Workspace returns the name of the currently configured workspace, corresponding
// to the desired named state.
//
// Workspace names are validated via use of the `WorkspaceOverridden` method.
func (m *Meta) Workspace() (string, error) {
current, overridden := m.WorkspaceOverridden()
if overridden && !validWorkspaceName(current) {
return "", errInvalidWorkspaceNameEnvVar
current, _, err := m.WorkspaceOverridden()
if err != nil {
return "", err
}
return current, nil
}

// WorkspaceOverridden returns the name of the currently configured workspace,
// corresponding to the desired named state, as well as a bool saying whether
// this was set via the TF_WORKSPACE environment variable.
func (m *Meta) WorkspaceOverridden() (string, bool) {
//
// The method also validates the workspace name. If it's invalid, an error is
// returned alongside an empty string. The returned boolean will still reflect
// whether the workspace is set by ENV or not.
func (m *Meta) WorkspaceOverridden() (string, bool, error) {
if envVar := os.Getenv(WorkspaceNameEnvVar); envVar != "" {
return envVar, true
if !validWorkspaceName(envVar) {
// Protect against invalid workspace names set via ENV.
return "", true, errInvalidWorkspaceNameEnvVar
}
return envVar, true, nil
}

envData, err := ioutil.ReadFile(filepath.Join(m.DataDir(), local.DefaultWorkspaceFile))
envData, err := os.ReadFile(filepath.Join(m.DataDir(), local.DefaultWorkspaceFile))
current := string(bytes.TrimSpace(envData))
if current == "" {
current = backend.DefaultStateName
}
if !m.bypassWorkspaceNameValidityCheck && !validWorkspaceName(current) {
// This check is active in every command that uses a backend.
// It is selectively disabled in commands that are recommended for recovering from an invalid workspace.
err := fmt.Errorf("Invalid workspace name: The selected workspace described in %q has an invalid name. This suggests that the file contents were edited by something other than Terraform. To select a different, valid workspace name use commands `terraform workspace select` or `terraform workspace select -or-create`.", filepath.Join(m.DataDir(), local.DefaultWorkspaceFile))
return "", false, err
}

if err != nil && !os.IsNotExist(err) {
// always return the default if we can't get a workspace name
log.Printf("[ERROR] failed to read current workspace: %s", err)
}

return current, false
return current, false, nil
}

// SetWorkspace saves the given name as the current workspace in the local
Expand Down
13 changes: 13 additions & 0 deletions internal/command/meta_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ func TestMeta_StatePersistInterval(t *testing.T) {
})
}

// Invalid workspace names provided via ENV are validated.
func TestMeta_Workspace_override(t *testing.T) {
m := new(Meta)

Expand Down Expand Up @@ -317,7 +318,19 @@ func TestMeta_Workspace_invalidSelected(t *testing.T) {

m := new(Meta)

// Normally, errors are returned when selecting an invalid workspace.
ws, err := m.Workspace()
if ws != "" {
t.Errorf("Unexpected workspace\n got: %s\nwant: %s\n", ws, workspace)
}
if err == nil {
t.Errorf("Expected error but got none")
}

// But it is possible to select an invalid workspace, enabling some
// commands to interact with and correct the issue.
m.bypassWorkspaceNameValidityCheck = true
ws, err = m.Workspace()
if ws != workspace {
t.Errorf("Unexpected workspace\n got: %s\nwant: %s\n", ws, workspace)
}
Expand Down
98 changes: 98 additions & 0 deletions internal/command/workspace_command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1564,3 +1564,101 @@ func TestWorkspace_list_jsonOutput(t *testing.T) {
t.Fatalf("expected stderr to be empty, but got: %s", output.Stderr())
}
}

// Show how a user can recover from the .terraform/environment file being edited out-of-band
// and containing an invalid workspace name. In particular this scenario shows that the path
// traversal attempt is blocked and that users can select a different workspace to recover.
func TestInvalidWorkspaceSelectedOutOfBand(t *testing.T) {
td := t.TempDir()
t.Chdir(td)

// Create some config
cfg := `output "greeting" {
value = "hello"
}`

if err := os.WriteFile("main.tf", []byte(cfg), 0600); err != nil {
t.Fatal(err)
}

// Initialize the working directory
ui := new(cli.MockUi)
view, done := testView(t)
meta := Meta{
Ui: ui,
View: view,
}
initCmd := &InitCommand{
Meta: meta,
}
if code := initCmd.Run(nil); code != 0 {
t.Fatalf("unexpected non-zero exit code: %d\n output: %s", code, done(t).All())
}

// Make a custom workspace.
customWorkspace := "custom"
ui = new(cli.MockUi)
view, done = testView(t)
meta.Ui = ui
meta.View = view
newCmd := &WorkspaceNewCommand{
Meta: meta,
}
if code := newCmd.Run([]string{customWorkspace}); code != 0 {
t.Fatalf("unexpected non-zero exit code: %d\n output: %s", code, done(t).All())
}

// Manually edit the .terraform/environment file to have an invalid workspace name.
invalidWorkspace := "../invalid"
path := filepath.Join(meta.DataDir(), local.DefaultWorkspaceFile)
if err := os.WriteFile(path, []byte(invalidWorkspace), 0600); err != nil {
t.Fatal(err)
}

expectedError := "Invalid workspace name"

// Errors block users from performing init with the invalid workspace selected.
ui = new(cli.MockUi)
view, done = testView(t)
meta.Ui = ui
meta.View = view
initCmd = &InitCommand{
Meta: meta,
}
if code := initCmd.Run(nil); code != 1 {
t.Fatalf("expected exit code 1, got %d\n output: %s", code, done(t).All())
}
if !strings.Contains(done(t).All(), expectedError) {
t.Fatalf("expected error message %q, got %q", expectedError, done(t).All())
}

// Errors block users from performing apply with the invalid workspace selected.
ui = new(cli.MockUi)
view, done = testView(t)
meta.Ui = ui
meta.View = view
applyCmd := &ApplyCommand{
Meta: meta,
}
if code := applyCmd.Run(nil); code != 1 {
t.Fatalf("expected exit code 1, got %d\n output: %s", code, done(t).All())
}
if !strings.Contains(done(t).All(), expectedError) {
t.Fatalf("expected error message %q, got %q", expectedError, done(t).All())
}

// Users can select a different workspace to recover from the issue
ui = new(cli.MockUi)
view, _ = testView(t)
meta.Ui = ui
meta.View = view
selectCmd := &WorkspaceSelectCommand{
Meta: meta,
}
if code := selectCmd.Run([]string{"default"}); code != 0 {
t.Fatalf("expected exit code 0, got %d\n output: %s", code, ui.ErrorWriter)
}

// In this scenario the invalid workspace name only exists in the .terraform/environment file.
// Therefore there's no 'bad' workspace that really exists and needs to be deleted.
}
7 changes: 6 additions & 1 deletion internal/command/workspace_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,12 @@ func (c *WorkspaceListCommand) Run(rawArgs []string) int {
return 1
}

env, isOverridden := c.WorkspaceOverridden()
env, isOverridden, err := c.WorkspaceOverridden()
if err != nil {
diags = diags.Append(err)
view.List("", nil, diags)
return 1
}

if isOverridden {
warn := tfdiags.Sourceless(
Expand Down
6 changes: 5 additions & 1 deletion internal/command/workspace_new.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@ func (c *WorkspaceNewCommand) Run(rawArgs []string) int {

// You can't ask to create a workspace when you're overriding the
// workspace name to be something different.
if current, isOverridden := c.WorkspaceOverridden(); current != workspace && isOverridden {
//
// Any errors about the ENV's value we can ignore as we're erroring
// already due to it being set.
current, isOverridden, _ := c.WorkspaceOverridden()
if current != workspace && isOverridden {
c.Ui.Error(envIsOverriddenNewError)
return 1
}
Expand Down
6 changes: 5 additions & 1 deletion internal/command/workspace_select.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,17 @@ func (c *WorkspaceSelectCommand) Run(rawArgs []string) int {
}

// Block selecting a workspace if an environment variable will override the new selection anyway.
current, isOverridden := c.WorkspaceOverridden()
//
// We ignore errors raised about the value of the override ENV, or the value of the currently selected
// workspace; this command should help users recover from those errors, not block them.
current, isOverridden, _ := c.WorkspaceOverridden()
if isOverridden {
c.Ui.Error(envIsOverriddenSelectError)
return 1
}

// Load the backend
c.bypassWorkspaceNameValidityCheck = true // allow selecting a new workspace when the current one is invalid.
configPath := c.WorkingDir.RootModuleDir()
b, diags := c.backend(configPath, args.ViewType)
if diags.HasErrors() {
Expand Down
Loading