// Copyright (c) The OpenTofu Authors // SPDX-License-Identifier: MPL-2.0 // Copyright (c) 2023 HashiCorp, Inc. // SPDX-License-Identifier: MPL-2.0 package statefile import ( "encoding/json" "errors" "fmt" "io" "os" version "github.com/hashicorp/go-version" "github.com/opentofu/opentofu/internal/encryption" "github.com/opentofu/opentofu/internal/tfdiags" tfversion "github.com/opentofu/opentofu/version" ) // ErrNoState is returned by ReadState when the state file is empty. var ErrNoState = errors.New("no state") // ErrUnusableState is an error wrapper to indicate that we *think* the input // represents state data, but can't use it for some reason (as explained in the // error text). Callers can check against this type with errors.As() if they // need to distinguish between corrupt state and more fundamental problems like // an empty file. type ErrUnusableState struct { inner error } func errUnusable(err error) *ErrUnusableState { return &ErrUnusableState{inner: err} } func (e *ErrUnusableState) Error() string { return e.inner.Error() } func (e *ErrUnusableState) Unwrap() error { return e.inner } // Read reads a state from the given reader. // // Legacy state format versions 1 through 3 are supported, but the result will // contain object attributes in the deprecated "flatmap" format and so must // be upgraded by the caller before use. // // If the state file is empty, the special error value ErrNoState is returned. // Otherwise, the returned error might be a wrapper around tfdiags.Diagnostics // potentially describing multiple errors. func Read(r io.Reader, enc encryption.StateEncryption) (*File, error) { // Some callers provide us a "typed nil" *os.File here, which would // cause us to panic below if we tried to use it. if f, ok := r.(*os.File); ok && f == nil { return nil, ErrNoState } var diags tfdiags.Diagnostics // We actually just buffer the whole thing in memory, because states are // generally not huge and we need to do be able to sniff for a version // number before full parsing. src, err := io.ReadAll(r) if err != nil { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Failed to read state file", fmt.Sprintf("The state file could not be read: %s", err), )) return nil, diags.Err() } if len(src) == 0 { return nil, ErrNoState } decrypted, status, err := enc.DecryptState(src) if err != nil { return nil, err } state, err := readState(decrypted) if err != nil { return nil, err } if state == nil { // Should never happen panic("readState returned nil state with no errors") } state.EncryptionStatus = status return state, diags.Err() } func readState(src []byte) (*File, error) { var diags tfdiags.Diagnostics if looksLikeVersion0(src) { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, unsupportedFormat, // This is a user-facing usage of OpenTofu but refers to a very old historical version of OpenTofu // which has no corresponding OpenTofu version, and is unlikely to get one. // If we ever get OpenTofu 0.6.16 and 0.7.x, we should update this message to mention OpenTofu instead. "The state is stored in a legacy binary format that is not supported since Terraform v0.7. To continue, first upgrade the state using Terraform 0.6.16 or earlier.", )) return nil, errUnusable(diags.Err()) } version, versionDiags := sniffJSONStateVersion(src) diags = diags.Append(versionDiags) if versionDiags.HasErrors() { // This is the last point where there's a really good chance it's not a // state file at all. Past here, we'll assume errors mean it's state but // we can't use it. return nil, diags.Err() } var result *File var err error switch version { case 0: diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, unsupportedFormat, "The state file uses JSON syntax but has a version number of zero. There was never a JSON-based state format zero, so this state file is invalid and cannot be processed.", )) case 1: result, diags = readStateV1(src) case 2: result, diags = readStateV2(src) case 3: result, diags = readStateV3(src) case 4: result, diags = readStateV4(src) default: thisVersion := tfversion.SemVer.String() creatingVersion := sniffJSONStateTerraformVersion(src) switch { case creatingVersion != "": diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, unsupportedFormat, fmt.Sprintf("The state file uses format version %d, which is not supported by OpenTofu %s. This state file was created by OpenTofu %s.", version, thisVersion, creatingVersion), )) default: diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, unsupportedFormat, fmt.Sprintf("The state file uses format version %d, which is not supported by OpenTofu %s. This state file may have been created by a newer version of OpenTofu.", version, thisVersion), )) } } if diags.HasErrors() { err = errUnusable(diags.Err()) } return result, err } func sniffJSONStateVersion(src []byte) (uint64, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics type VersionSniff struct { Version *uint64 `json:"version"` } var sniff VersionSniff err := json.Unmarshal(src, &sniff) if err != nil { switch tErr := err.(type) { case *json.SyntaxError: diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, unsupportedFormat, fmt.Sprintf("The state file could not be parsed as JSON: syntax error at byte offset %d.", tErr.Offset), )) case *json.UnmarshalTypeError: diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, unsupportedFormat, fmt.Sprintf("The version in the state file is %s. A positive whole number is required.", tErr.Value), )) default: diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, unsupportedFormat, "The state file could not be parsed as JSON.", )) } } if sniff.Version == nil { encrypted, err := encryption.IsEncryptionPayload(src) if err != nil { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, unsupportedFormat, fmt.Sprintf("The state file can not be checked for presence of encryption: %s", err.Error()), )) return 0, diags } if encrypted { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, unsupportedFormat, "This state file is encrypted and can not be read without an encryption configuration", )) return 0, diags } diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, unsupportedFormat, "The state file does not have a \"version\" attribute, which is required to identify the format version.", )) return 0, diags } return *sniff.Version, diags } // sniffJSONStateTerraformVersion attempts to sniff the OpenTofu version // specification from the given state file source code. The result is either // a version string or an empty string if no version number could be extracted. // // This is a best-effort function intended to produce nicer error messages. It // should not be used for any real processing. func sniffJSONStateTerraformVersion(src []byte) string { type VersionSniff struct { Version string `json:"terraform_version"` } var sniff VersionSniff err := json.Unmarshal(src, &sniff) if err != nil { return "" } // Attempt to parse the string as a version so we won't report garbage // as a version number. _, err = version.NewVersion(sniff.Version) if err != nil { return "" } return sniff.Version } // unsupportedFormat is a diagnostic summary message for when the state file // seems to not be a state file at all, or is not a supported version. // // Use invalidFormat instead for the subtly-different case of "this looks like // it's intended to be a state file but it's not structured correctly". const unsupportedFormat = "Unsupported state file format" const upgradeFailed = "State format upgrade failed"