From a3ca0085c170f2af56b0f54c4b6f23ace565d91c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miloslav=20Trma=C4=8D?= Date: Fri, 6 Feb 2026 22:52:25 +0100 Subject: [PATCH] Add image/pkg/cli/basetls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miloslav Trmač --- image/docs/containers-tls-details.yaml.5.md | 84 +++++++ image/pkg/cli/basetls/basetls.go | 219 ++++++++++++++++++ image/pkg/cli/basetls/basetls_test.go | 138 +++++++++++ .../tlsdetails/testdata/all-fields.yaml | 8 + .../tlsdetails/testdata/empty-fields.yaml | 3 + .../basetls/tlsdetails/testdata/empty.yaml | 1 + .../tlsdetails/testdata/invalid-values.yaml | 5 + .../pkg/cli/basetls/tlsdetails/tlsdetails.go | 59 +++++ .../cli/basetls/tlsdetails/tlsdetails_test.go | 114 +++++++++ 9 files changed, 631 insertions(+) create mode 100644 image/docs/containers-tls-details.yaml.5.md create mode 100644 image/pkg/cli/basetls/basetls.go create mode 100644 image/pkg/cli/basetls/basetls_test.go create mode 100644 image/pkg/cli/basetls/tlsdetails/testdata/all-fields.yaml create mode 100644 image/pkg/cli/basetls/tlsdetails/testdata/empty-fields.yaml create mode 100644 image/pkg/cli/basetls/tlsdetails/testdata/empty.yaml create mode 100644 image/pkg/cli/basetls/tlsdetails/testdata/invalid-values.yaml create mode 100644 image/pkg/cli/basetls/tlsdetails/tlsdetails.go create mode 100644 image/pkg/cli/basetls/tlsdetails/tlsdetails_test.go diff --git a/image/docs/containers-tls-details.yaml.5.md b/image/docs/containers-tls-details.yaml.5.md new file mode 100644 index 0000000000..a5fcfc9d5b --- /dev/null +++ b/image/docs/containers-tls-details.yaml.5.md @@ -0,0 +1,84 @@ +% CONTAINERS-TLS-DETAILS.YAML 5 container-libs TLS details file format +% Miloslav Trmač +% February 2026 + +# NAME +containers-tls-details.yaml - syntax for the container-libs TLS details parameter file + +# DESCRIPTION + +The TLS details parameter file is accepted by various projects using the go.podman.io/* libraries. +There is no default location for these files; they are user-managed, and a path is provided on the CLI, +e.g. `skopeo --tls-details=`_details-file_`.yaml copy …`. + +# WARNINGS + +The `--tls-details` options, and this file format, should only rarely be used. +If this mechanism is not used, the software is expected to use appropriate defaults which will vary over time, +depending on version of the software, version of the Go standard library, +or platform’s configuration (e.g. `GODEBUG` values; or, not as of early 2026, but potentially, **crypto-policies**(7)). + +These options _only_ affect the programs which provide the `--tls-details` option; +they don't affect other executables (e.g. **git**(1), **ssh**(1)) that may be executed internally to perform another operation. + +There are some known gaps in the implementation of these options. +We hope to fix that over time, but in the meantime, careful testing feature by feature is recommended. +Known gaps include network operations performed while creating sigstore signatures (communicating with Rekor, OIDC servers, Fulcio). + +# FORMAT + +The TLS details files use YAML. All fields are optional. + +- `minVersion` + + The minimum TLS version to use throughout the program. + If not set, defaults to a reasonable default that may change over time. + + Users should generally not use this option and hard-code a version unless they have a process + to ensure that the value will be kept up to date. + +- `cipherSuites` + + The allowed TLS cipher suites to use throughout the program. + The value is an array of IANA TLS Cipher Suites names. + + If not set, defaults to a reasonable default that may change over time; + if set to an empty array, prohibits using all cipher suites. + + **Warning:** Almost no-one should ever use this option. + Use it only if you have a bureaucracy that requires a specific list, + and if you are confident that this bureaucracy will still exist, + and will bring you an updated list when necessary, + many years from now. + + **Warning:** The effectiveness of this option is limited by capabilities of the Go standard library; + e.g., as of Go 1.25, it is not possible to change which cipher suites are used in TLS 1.3. + +- `namedGroups` + + The allowed TLS named groups to use throughout the program. + The value is an array of IANA TLS Supported Groups names. + + If not set, defaults to a reasonable default that may change over time. + + **Warning:** Almost no-one should ever use this option. + Use it only if you have a bureaucracy that requires a specific list, + and if you are confident that this bureaucracy will still exist, + and will bring you an updated list when necessary, + many years from now. + +# EXAMPLE + +```yaml +minVersion: "1.2" +cipherSuites: + - "TLS_AES_128_GCM_SHA256" + - "TLS_CHACHA20_POLY1305_SHA256" +namedGroups: + - "secp256r1" + - "secp384r1" + - "x25519" +``` + +# SEE ALSO +buildah(1), podman(1), skopeo(1) diff --git a/image/pkg/cli/basetls/basetls.go b/image/pkg/cli/basetls/basetls.go new file mode 100644 index 0000000000..1558e8f4ea --- /dev/null +++ b/image/pkg/cli/basetls/basetls.go @@ -0,0 +1,219 @@ +// Package basetls encapsulates a set of base TLS settings (not keys/certificates) +// configured via containers-tls-details.yaml(5). +// +// CLI integration should generally be done using c/image/pkg/cli/basetls/tlsdetails instead +// of using the TLSDetailsFile directly. +package basetls + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "slices" +) + +// Config encapsulates user’s choices about base TLS settings, typically +// configured via containers-tls-details.yaml(5). +// +// Most codebases should pass around the resulting *tls.Config, without depending on this subpackage; +// this primarily exists as a separate type to allow passing the configuration around within (version-matched) RPC systems, +// using the MarshalText/UnmarshalText methods. +type Config struct { + // We keep the text representation because we start with it, and this way we don't have + // to implement formatting back to text. This is an internal detail, so we can change that later. + text TLSDetailsFile + config *tls.Config // Parsed from .text, both match +} + +// TLSDetailsFile contains a set of TLS options. +// +// To consume such a file, most callers should use c/image/pkg/cli/basetls/tlsdetails instead +// of dealing with this type explicitly. +// +// This type is exported primarily to allow creating parameter files programmatically +// (and eventually the tlsdetails subpackage should provide an API to convert this type into +// the appropriate file contents, so that callers don't need to do that manually). +type TLSDetailsFile struct { + // Keep this in sync with docs/containers-tls-details.yaml.5.md ! + + MinVersion string `yaml:"minVersion,omitempty"` // If set, minimum version to use throughout the program. + CipherSuites []string `yaml:"cipherSuites,omitempty"` // If set, allowed TLS cipher suites to use throughout the program. + NamedGroups []string `yaml:"namedGroups,omitempty"` // If set, allowed TLS named groups to use throughout the program. +} + +// NewFromTLSDetails creates a Config from a TLSDetailsFile. +func NewFromTLSDetails(details *TLSDetailsFile) (*Config, error) { + res := Config{ + text: TLSDetailsFile{}, + config: &tls.Config{}, + } + configChanged := false + for _, fn := range []func(input *TLSDetailsFile) (bool, error){ + res.parseMinVersion, + res.parseCipherSuites, + res.parseNamedGroups, + } { + changed, err := fn(details) + if err != nil { + return nil, err + } + if changed { + configChanged = true + } + } + + if !configChanged { + res.config = nil + } + return &res, nil +} + +// tlsVersions maps TLS version strings to their crypto/tls constants. +// We could use the `tls.VersionName` names, but those are verbose and contain spaces; +// similarly the OpenShift enum values (“VersionTLS11”) are unergonomic. +var tlsVersions = map[string]uint16{ + "1.0": tls.VersionTLS10, + "1.1": tls.VersionTLS11, + "1.2": tls.VersionTLS12, + "1.3": tls.VersionTLS13, +} + +func (c *Config) parseMinVersion(input *TLSDetailsFile) (bool, error) { + if input.MinVersion == "" { + return false, nil + } + v, ok := tlsVersions[input.MinVersion] + if !ok { + return false, fmt.Errorf("unrecognized TLS minimum version %q", input.MinVersion) + } + c.text.MinVersion = input.MinVersion + c.config.MinVersion = v + return true, nil +} + +// cipherSuitesByName returns a map from cipher suite name to its ID. +func cipherSuitesByName() map[string]uint16 { + // The Go standard library uses IANA names and already contains the mapping (for relevant values) + // sadly we still need to turn it into a lookup map. + suites := make(map[string]uint16) + for _, cs := range tls.CipherSuites() { + suites[cs.Name] = cs.ID + } + for _, cs := range tls.InsecureCipherSuites() { + suites[cs.Name] = cs.ID + } + return suites +} + +func (c *Config) parseCipherSuites(input *TLSDetailsFile) (bool, error) { + if input.CipherSuites == nil { + return false, nil + } + suitesByName := cipherSuitesByName() + ids := []uint16{} + for _, name := range input.CipherSuites { + id, ok := suitesByName[name] + if !ok { + return false, fmt.Errorf("unrecognized TLS cipher suite %q", name) + } + ids = append(ids, id) + } + c.text.CipherSuites = slices.Clone(input.CipherSuites) + c.config.CipherSuites = ids + return true, nil +} + +// groupsByName maps curve/group names to their tls.CurveID. +// The names match IANA TLS Supported Groups registry. +// +// Yes, the x25519 names differ in capitalization. +// Go’s tls.CurveID has a .String() method, but it +// uses the Go names. +var groupsByName = map[string]tls.CurveID{ + "secp256r1": tls.CurveP256, + "secp384r1": tls.CurveP384, + "secp521r1": tls.CurveP521, + "x25519": tls.X25519, + "X25519MLKEM768": tls.X25519MLKEM768, +} + +func (c *Config) parseNamedGroups(input *TLSDetailsFile) (bool, error) { + if input.NamedGroups == nil { + return false, nil + } + ids := []tls.CurveID{} + for _, name := range input.NamedGroups { + id, ok := groupsByName[name] + if !ok { + return false, fmt.Errorf("unrecognized TLS named group %q", name) + } + ids = append(ids, id) + } + c.text.NamedGroups = slices.Clone(input.NamedGroups) + c.config.CurvePreferences = ids + return true, nil +} + +// TLSConfig returns a *tls.Config matching the provided settings. +// If c contains no settings, it returns nil. +// Otherwise, the returned *tls.Config is freshly allocated and the caller can modify it as needed. +func (c *Config) TLSConfig() *tls.Config { + if c.config == nil { + return nil + } + return c.config.Clone() +} + +// marshaledSerialization is the data we use in MarshalText/UnmarshalText, +// marshaled using JSON. +// +// Note that the file format is using YAML, but we use JSON, to minimize dependencies +// in backend code where we don't need comments and the brackets are not annoying users. +type marshaledSerialization struct { + Version int + Data TLSDetailsFile +} + +const marshaledSerializationVersion1 = 1 + +// MarshalText serializes c to a text representation. +// +// The representation is intended to be reasonably stable across updates to c/image, +// but the consumer must not be older than the producer. +func (c Config) MarshalText() ([]byte, error) { + data := marshaledSerialization{ + Version: marshaledSerializationVersion1, + Data: c.text, + } + return json.Marshal(data) +} + +// UnmarshalText parses the output of MarshalText. +// +// The format is otherwise undocumented and we do not promise ongoing compatibility with producers external to this package. +func (c *Config) UnmarshalText(text []byte) error { + var data marshaledSerialization + + // In the future, this should be an even stricter parser, e.g. refusing duplicate fields + // and requiring a case-sensitive field name match. + decoder := json.NewDecoder(bytes.NewReader(text)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&data); err != nil { + return err + } + if decoder.More() { + return errors.New("unexpected extra data after a JSON object") + } + + if data.Version != marshaledSerializationVersion1 { + return fmt.Errorf("unsupported version %d", data.Version) + } + v, err := NewFromTLSDetails(&data.Data) + if err != nil { + return err + } + *c = *v + return nil +} diff --git a/image/pkg/cli/basetls/basetls_test.go b/image/pkg/cli/basetls/basetls_test.go new file mode 100644 index 0000000000..bb1840a4ea --- /dev/null +++ b/image/pkg/cli/basetls/basetls_test.go @@ -0,0 +1,138 @@ +package basetls + +import ( + "crypto/tls" + "encoding" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var ( + _ encoding.TextMarshaler = Config{} + _ encoding.TextMarshaler = (*Config)(nil) + _ encoding.TextUnmarshaler = (*Config)(nil) +) + +func TestNewFromTLSDetails(t *testing.T) { + // This tests both the initial parsing, and marshaling+unmarshaling. + for _, tc := range []struct { + details TLSDetailsFile + expected *tls.Config + }{ + { + details: TLSDetailsFile{MinVersion: "1.2", CipherSuites: []string{"TLS_AES_128_GCM_SHA256", "TLS_CHACHA20_POLY1305_SHA256"}, NamedGroups: []string{"secp256r1", "secp384r1", "x25519"}}, + expected: &tls.Config{MinVersion: tls.VersionTLS12, CipherSuites: []uint16{tls.TLS_AES_128_GCM_SHA256, tls.TLS_CHACHA20_POLY1305_SHA256}, CurvePreferences: []tls.CurveID{tls.CurveP256, tls.CurveP384, tls.X25519}}, + }, + } { + baseTLS, err := NewFromTLSDetails(&tc.details) + require.NoError(t, err) + assert.Equal(t, tc.expected, baseTLS.TLSConfig()) + + marshaled, err := baseTLS.MarshalText() + require.NoError(t, err) + var unmarshaled Config + err = unmarshaled.UnmarshalText(marshaled) + require.NoError(t, err) + assert.Equal(t, tc.expected, unmarshaled.TLSConfig()) + + marshaled2, err := unmarshaled.MarshalText() + require.NoError(t, err) + assert.Equal(t, marshaled, marshaled2) // NOT an API promise, and this assumes JSON marshaling is deterministic + var unmarshaled2 Config + err = unmarshaled2.UnmarshalText(marshaled2) + require.NoError(t, err) + assert.Equal(t, baseTLS.TLSConfig(), unmarshaled2.TLSConfig()) + } +} + +func TestBaseTLSParseMinVersion(t *testing.T) { + for _, tc := range []struct { + version string + expected *tls.Config + expectError bool + }{ + {"", nil, false}, + {"1.0", &tls.Config{MinVersion: tls.VersionTLS10}, false}, + {"1.1", &tls.Config{MinVersion: tls.VersionTLS11}, false}, + {"1.2", &tls.Config{MinVersion: tls.VersionTLS12}, false}, + {"1.3", &tls.Config{MinVersion: tls.VersionTLS13}, false}, + {"this_is_invalid", nil, true}, + } { + baseTLS, err := NewFromTLSDetails(&TLSDetailsFile{MinVersion: tc.version}) + if tc.expectError { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tc.expected, baseTLS.TLSConfig()) + } + } +} + +func TestBaseTLSParseCipherSuites(t *testing.T) { + validSuites := tls.CipherSuites() + require.True(t, len(validSuites) >= 2) + suite0, suite1 := validSuites[0], validSuites[1] + + for _, tc := range []struct { + suites []string + expected *tls.Config + expectError bool + }{ + {nil, nil, false}, // empty + {[]string{}, &tls.Config{CipherSuites: []uint16{}}, false}, // no cipher suites + {[]string{suite0.Name}, &tls.Config{CipherSuites: []uint16{suite0.ID}}, false}, + {[]string{suite0.Name, suite1.Name}, &tls.Config{CipherSuites: []uint16{suite0.ID, suite1.ID}}, false}, + {[]string{"this_is_invalid"}, nil, true}, + } { + baseTLS, err := NewFromTLSDetails(&TLSDetailsFile{CipherSuites: tc.suites}) + if tc.expectError { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tc.expected, baseTLS.TLSConfig()) + } + } +} + +func TestBaseTLSParseNamedGroups(t *testing.T) { + for _, tc := range []struct { + groups []string + expected *tls.Config + expectError bool + }{ + {nil, nil, false}, // empty + {[]string{}, &tls.Config{CurvePreferences: []tls.CurveID{}}, false}, // no named groups + {[]string{"x25519"}, &tls.Config{CurvePreferences: []tls.CurveID{tls.X25519}}, false}, + {[]string{"secp256r1"}, &tls.Config{CurvePreferences: []tls.CurveID{tls.CurveP256}}, false}, + {[]string{"secp384r1"}, &tls.Config{CurvePreferences: []tls.CurveID{tls.CurveP384}}, false}, + {[]string{"secp521r1"}, &tls.Config{CurvePreferences: []tls.CurveID{tls.CurveP521}}, false}, + {[]string{"x25519", "secp256r1"}, &tls.Config{CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256}}, false}, + {[]string{"this_is_invalid"}, nil, true}, + } { + baseTLS, err := NewFromTLSDetails(&TLSDetailsFile{NamedGroups: tc.groups}) + if tc.expectError { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tc.expected, baseTLS.TLSConfig()) + } + } +} + +func TestBaseTLSUnmarshalText(t *testing.T) { + // General correctness and round-trip safety is tested in TestNewFromTLSDetails. + for _, json := range []string{ + `¬ a valid JSON`, + `{}`, // missing version + `{"Version": 9999, "Data":{}}`, + `{"Version": 1, "Data":{}}{"Version": 1, "Data":{}}`, + `{"Version": 1, "Data":{ "MinVersion": "this_is_invalid" }}`, + } { + var baseTLS Config + err := baseTLS.UnmarshalText([]byte(json)) + assert.Error(t, err, json) + t.Logf("error: %v", err) + } +} diff --git a/image/pkg/cli/basetls/tlsdetails/testdata/all-fields.yaml b/image/pkg/cli/basetls/tlsdetails/testdata/all-fields.yaml new file mode 100644 index 0000000000..8a63ffde73 --- /dev/null +++ b/image/pkg/cli/basetls/tlsdetails/testdata/all-fields.yaml @@ -0,0 +1,8 @@ +minVersion: "1.2" +cipherSuites: + - "TLS_AES_128_GCM_SHA256" + - "TLS_CHACHA20_POLY1305_SHA256" +namedGroups: + - "secp256r1" + - "secp384r1" + - "x25519" diff --git a/image/pkg/cli/basetls/tlsdetails/testdata/empty-fields.yaml b/image/pkg/cli/basetls/tlsdetails/testdata/empty-fields.yaml new file mode 100644 index 0000000000..7e8f36c1d8 --- /dev/null +++ b/image/pkg/cli/basetls/tlsdetails/testdata/empty-fields.yaml @@ -0,0 +1,3 @@ +minVersion: "" +cipherSuites: [] +namedGroups: [] diff --git a/image/pkg/cli/basetls/tlsdetails/testdata/empty.yaml b/image/pkg/cli/basetls/tlsdetails/testdata/empty.yaml new file mode 100644 index 0000000000..9e26dfeeb6 --- /dev/null +++ b/image/pkg/cli/basetls/tlsdetails/testdata/empty.yaml @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/image/pkg/cli/basetls/tlsdetails/testdata/invalid-values.yaml b/image/pkg/cli/basetls/tlsdetails/testdata/invalid-values.yaml new file mode 100644 index 0000000000..f22f919a83 --- /dev/null +++ b/image/pkg/cli/basetls/tlsdetails/testdata/invalid-values.yaml @@ -0,0 +1,5 @@ +minVersion: "this is not a valid version" +cipherSuites: + - "this is not a valid cipher suite" +namedGroups: + - "this is not a valid named group" diff --git a/image/pkg/cli/basetls/tlsdetails/tlsdetails.go b/image/pkg/cli/basetls/tlsdetails/tlsdetails.go new file mode 100644 index 0000000000..fca6bed917 --- /dev/null +++ b/image/pkg/cli/basetls/tlsdetails/tlsdetails.go @@ -0,0 +1,59 @@ +// Package tlsdetails implements the containers-tls-details.yaml(5) file format. +// +// Recommended CLI integration is by a --tls-details flag parsed using BaseTLSFromOptionalFile, with the following documentation: +// +// --tls-details is a path to a containers-tls-details.yaml(5) file, affecting TLS behavior throughout the program. +// +// If not set, defaults to a reasonable default that may change over time (depending on system’s global policy, +// version of the program, version of the Go language, and the like). +// +// Users should generally not use this option unless they have a process to ensure that the configuration will be kept up to date. +package tlsdetails + +import ( + "bytes" + "fmt" + "os" + + "go.podman.io/image/v5/pkg/cli/basetls" + "gopkg.in/yaml.v3" +) + +// BaseTLSFromOptionalFile returns a basetls.Config matching a containers-tls-details.yaml file at the specified path. +// If path is "", it returns a valid basetls.Config with no settings (where config.TLSConfig() will return nil). +func BaseTLSFromOptionalFile(path string) (*basetls.Config, error) { + if path == "" { + return basetls.NewFromTLSDetails(&basetls.TLSDetailsFile{}) + } + return BaseTLSFromFile(path) +} + +// BaseTLSFromFile returns a basetls.Config matching a containers-tls-details.yaml file at the specified path. +func BaseTLSFromFile(path string) (*basetls.Config, error) { + details, err := ParseFile(path) + if err != nil { + return nil, err + } + res, err := basetls.NewFromTLSDetails(details) + if err != nil { + return nil, fmt.Errorf("parsing TLS details %q: %w", path, err) + } + return res, nil +} + +// ParseFile parses a basetls.TLSDetailsFile at the specified path. +// +// Most consumers of the parameter file should use BaseTLSFromFile or BaseTLSFromOptionalFile instead. +func ParseFile(path string) (*basetls.TLSDetailsFile, error) { + var res basetls.TLSDetailsFile + source, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading %q: %w", path, err) + } + dec := yaml.NewDecoder(bytes.NewReader(source)) + dec.KnownFields(true) + if err = dec.Decode(&res); err != nil { + return nil, fmt.Errorf("parsing %q: %w", path, err) + } + return &res, nil +} diff --git a/image/pkg/cli/basetls/tlsdetails/tlsdetails_test.go b/image/pkg/cli/basetls/tlsdetails/tlsdetails_test.go new file mode 100644 index 0000000000..7af7cede7b --- /dev/null +++ b/image/pkg/cli/basetls/tlsdetails/tlsdetails_test.go @@ -0,0 +1,114 @@ +package tlsdetails + +import ( + "crypto/tls" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.podman.io/image/v5/pkg/cli/basetls" +) + +func TestBaseTLSFromOptionalFile(t *testing.T) { + for _, tc := range []struct { + path string + expected *tls.Config + }{ + {path: "", expected: nil}, + { + path: "testdata/all-fields.yaml", + expected: &tls.Config{ + MinVersion: tls.VersionTLS12, + CipherSuites: []uint16{tls.TLS_AES_128_GCM_SHA256, tls.TLS_CHACHA20_POLY1305_SHA256}, + CurvePreferences: []tls.CurveID{tls.CurveP256, tls.CurveP384, tls.X25519}, + }, + }, + {path: "testdata/empty.yaml", expected: nil}, + } { + baseTLS, err := BaseTLSFromOptionalFile(tc.path) + require.NoError(t, err) + require.NotNil(t, baseTLS) + assert.Equal(t, tc.expected, baseTLS.TLSConfig()) + } +} + +func TestBaseTLSFromFile(t *testing.T) { + for _, tc := range []struct { + path string + expected *tls.Config + }{ + { + path: "testdata/all-fields.yaml", + expected: &tls.Config{ + MinVersion: tls.VersionTLS12, + CipherSuites: []uint16{tls.TLS_AES_128_GCM_SHA256, tls.TLS_CHACHA20_POLY1305_SHA256}, + CurvePreferences: []tls.CurveID{tls.CurveP256, tls.CurveP384, tls.X25519}, + }, + }, + {path: "testdata/empty.yaml", expected: nil}, + } { + baseTLS, err := BaseTLSFromFile(tc.path) + require.NoError(t, err) + assert.Equal(t, tc.expected, baseTLS.TLSConfig()) + } + + for _, path := range []string{ + "/dev/null/this/does/not/exist", + "testdata/invalid-values.yaml", + } { + _, err := BaseTLSFromFile(path) + assert.Error(t, err, path) + t.Logf("error: %v", err) + } +} + +func TestParseFile(t *testing.T) { + // A minimal test of parsing; field validation and handling is a responsibility of pkg/cli/basetls. + for _, tc := range []struct { + path string + expected basetls.TLSDetailsFile + }{ + { + path: "testdata/all-fields.yaml", + expected: basetls.TLSDetailsFile{ + MinVersion: "1.2", + CipherSuites: []string{"TLS_AES_128_GCM_SHA256", "TLS_CHACHA20_POLY1305_SHA256"}, + NamedGroups: []string{"secp256r1", "secp384r1", "x25519"}, + }, + }, + { + path: "testdata/empty-fields.yaml", + expected: basetls.TLSDetailsFile{ + MinVersion: "", + CipherSuites: []string{}, + NamedGroups: []string{}, + }, + }, + { + path: "testdata/empty.yaml", + expected: basetls.TLSDetailsFile{}, + }, + } { + f, err := ParseFile(tc.path) + require.NoError(t, err) + assert.Equal(t, tc.expected, *f) + } + + _, err := ParseFile("/dev/null/this/does/not/exist") + assert.Error(t, err) + + for _, yaml := range []string{ + `minVersion: {}`, // not a string; (number-like text is auto-converted) + `cipherSuites: "not-an-array"`, // not a list of strings + `this-field-does-not-exist: true`, // unknown field + "minVersion: 1\nminVersion: 1.2", // duplicate field + } { + path := filepath.Join(t.TempDir(), "bad.yaml") + err := os.WriteFile(path, []byte(yaml), 0o600) + require.NoError(t, err) + _, err = ParseFile(path) + assert.Error(t, err, yaml) + } +}