diff --git a/storage/pkg/supported-digests/algorithm.go b/storage/pkg/supported-digests/algorithm.go new file mode 100644 index 0000000000..db761c0fe1 --- /dev/null +++ b/storage/pkg/supported-digests/algorithm.go @@ -0,0 +1,213 @@ +package supporteddigests + +// Package supporteddigests provides digest algorithm management for container tools. +// +// WARNING: This package is currently Work In Progress (WIP) and is ONLY intended +// for use within Podman, Buildah, and Skopeo. It should NOT be used by external +// applications or libraries, even if shipped in a stable release. The API may +// change without notice and is not considered stable for external consumption. +// Proceed with caution if you must use this package outside of the intended scope. + +// FIXME: Use go-digest directly and address all review comments in +// https://github.com/containers/container-libs/pull/374. This is *one* of the blockers +// for removing the WIP warning. + +import ( + "fmt" + "strings" + "sync" + + "github.com/opencontainers/go-digest" + "github.com/sirupsen/logrus" +) + +var ( + digestAlgorithm = digest.Canonical // Default to SHA256 + algorithmMutex sync.RWMutex // Protects digestAlgorithm from concurrent access +) + +// TmpDigestForNewObjects returns the current digest algorithm that will be used +// for computing digests of new objects (e.g., image layers, manifests, blobs). +// +// WARNING: This function is part of a WIP package intended only for Podman, +// Buildah, and Skopeo. Do not use in external applications. +// +// This function returns the globally configured digest algorithm for new object +// creation. It is thread-safe and can be called concurrently from multiple +// goroutines using RWMutex. The default value is SHA256 (digest.Canonical) on +// first call. +// +// This is a read-only operation that does not modify global state. The returned +// value reflects the current global configuration set by TmpSetDigestForNewObjects() +// or the default if never set. Multiple concurrent calls will return the same +// algorithm value. The algorithm is used for computing content hashes during +// image operations such as layer extraction, manifest generation, and blob storage. +func TmpDigestForNewObjects() digest.Algorithm { + algorithmMutex.RLock() + defer algorithmMutex.RUnlock() + return digestAlgorithm +} + +// TmpSetDigestForNewObjects sets the digest algorithm that will be used for +// computing digests of new objects (e.g., image layers, manifests, blobs). +// +// WARNING: This function is part of a WIP package intended only for Podman, +// Buildah, and Skopeo. Do not use in external applications. +// +// This function configures the globally shared digest algorithm for new object +// creation. It is thread-safe and can be called concurrently from multiple +// goroutines using RWMutex. Changes affect all subsequent calls to +// TmpDigestForNewObjects(). +// +// The function validates the algorithm and returns an error for unsupported values. +// Supported algorithms are SHA256, SHA512, or empty string (which defaults to SHA256). +// This is typically used to configure the digest algorithm for the process where +// an optional --digest flag is provided. For example: "podman|buildah build --digest sha512" +// to configure the digest algorithm for the build process. +// +// The setting persists for the lifetime of the process. This is a write operation +// that modifies global state atomically. Invalid algorithms are rejected without +// changing the current setting. Empty string is treated as a request to reset to +// the default (SHA256). Existing digest values are not affected by algorithm changes. +func TmpSetDigestForNewObjects(algorithm digest.Algorithm) error { + algorithmMutex.Lock() + defer algorithmMutex.Unlock() + + // Validate the digest type + switch algorithm { + case digest.SHA256, digest.SHA512: + logrus.Debugf("SetDigestAlgorithm: Setting digest algorithm to %s", algorithm.String()) + digestAlgorithm = algorithm + return nil + case "": + logrus.Debugf("SetDigestAlgorithm: Setting digest algorithm to default %s", digest.Canonical.String()) + digestAlgorithm = digest.Canonical // Default to sha256 + return nil + default: + return fmt.Errorf("unsupported digest algorithm: %q", algorithm) + } +} + +// IsSupportedDigestAlgorithm checks if the given algorithm is supported by this package. +// +// WARNING: This function is part of a WIP package intended only for Podman, +// Buildah, and Skopeo. Do not use in external applications. +// +// It returns true if the algorithm is explicitly supported (SHA256, SHA512) or if +// it's an empty string or digest.Canonical (both treated as SHA256 default). +// It returns false for any other algorithm including SHA384, MD5, etc. +// +// This is a pure function with no side effects and is thread-safe for concurrent +// calls from multiple goroutines. It is typically used for validation before +// calling TmpSetDigestForNewObjects(). +func IsSupportedDigestAlgorithm(algorithm digest.Algorithm) bool { + // Handle special cases first + if algorithm == "" || algorithm == digest.Canonical { + return true // Empty string and canonical are treated as default (SHA256) + } + + // Check against the list of supported algorithms + supportedAlgorithms := GetSupportedDigestAlgorithms() + for _, supported := range supportedAlgorithms { + if algorithm == supported { + return true + } + } + return false +} + +// GetSupportedDigestAlgorithms returns a list of all supported digest algorithms. +// +// WARNING: This function is part of a WIP package intended only for Podman, +// Buildah, and Skopeo. Do not use in external applications. +// +// It returns a slice containing all algorithms that can be used with +// TmpSetDigestForNewObjects(). Currently returns [SHA256, SHA512]. +// +// This is a pure function with no side effects and is thread-safe for concurrent +// calls from multiple goroutines. The returned slice should not be modified by +// callers. It is typically used for validation and algorithm enumeration. +func GetSupportedDigestAlgorithms() []digest.Algorithm { + return []digest.Algorithm{ + digest.SHA256, + digest.SHA512, + } +} + +// GetDigestAlgorithmName returns a human-readable name for the algorithm. +// +// WARNING: This function is part of a WIP package intended only for Podman, +// Buildah, and Skopeo. Do not use in external applications. +// +// It returns a standardized uppercase name for supported algorithms. The function +// is case-insensitive, so "sha256", "SHA256", "Sha256" all return "SHA256". +// It returns "SHA256 (canonical)" for digest.Canonical and "unknown" for +// unsupported algorithms. +// +// This is a pure function with no side effects and is thread-safe for concurrent +// calls from multiple goroutines. It is typically used for logging and user-facing +// display purposes. +func GetDigestAlgorithmName(algorithm digest.Algorithm) string { + // Normalize to lowercase for case-insensitive matching + normalized := strings.ToLower(algorithm.String()) + + switch normalized { + case "sha256": + return "SHA256" + case "sha512": + return "SHA512" + default: + if algorithm == digest.Canonical { + return "SHA256 (canonical)" + } + return "unknown" + } +} + +// GetDigestAlgorithmExpectedLength returns the expected hex string length for a given algorithm. +// +// WARNING: This function is part of a WIP package intended only for Podman, +// Buildah, and Skopeo. Do not use in external applications. +// +// It returns (length, true) for supported algorithms with known hex lengths. +// SHA256 returns (64, true) and SHA512 returns (128, true). It returns (0, false) +// for unsupported or unknown algorithms. The length represents the number of hex +// characters in the digest string. +// +// This is a pure function with no side effects and is thread-safe for concurrent +// calls from multiple goroutines. It is typically used for validation and algorithm +// detection from hex string lengths. +func GetDigestAlgorithmExpectedLength(algorithm digest.Algorithm) (int, bool) { + switch algorithm { + case digest.SHA256: + return 64, true + case digest.SHA512: + return 128, true + default: + // For future algorithms, this function can be extended + // to support additional algorithms as they are added + return 0, false + } +} + +// DetectDigestAlgorithmFromLength attempts to detect the digest algorithm from a hex string length. +// +// WARNING: This function is part of a WIP package intended only for Podman, +// Buildah, and Skopeo. Do not use in external applications. +// +// It returns (algorithm, true) if a supported algorithm matches the given length, +// or (empty, false) if no supported algorithm matches the length. It checks all +// supported algorithms against their expected hex lengths. +// +// This is a pure function with no side effects and is thread-safe for concurrent +// calls from multiple goroutines. It is typically used for reverse lookup when +// only the hex string length is known. Ambiguous lengths (if any) will return +// the first matching algorithm. +func DetectDigestAlgorithmFromLength(length int) (digest.Algorithm, bool) { + for _, algorithm := range GetSupportedDigestAlgorithms() { + if expectedLength, supported := GetDigestAlgorithmExpectedLength(algorithm); supported && expectedLength == length { + return algorithm, true + } + } + return digest.Algorithm(""), false +} diff --git a/storage/pkg/supported-digests/algorithm_test.go b/storage/pkg/supported-digests/algorithm_test.go new file mode 100644 index 0000000000..b49e90f0e4 --- /dev/null +++ b/storage/pkg/supported-digests/algorithm_test.go @@ -0,0 +1,298 @@ +package supporteddigests + +import ( + "sync" + "testing" + + "github.com/opencontainers/go-digest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTmpDigestForNewObjects(t *testing.T) { + // Test that TmpDigestForNewObjects returns the default algorithm (SHA256) + algorithm := TmpDigestForNewObjects() + assert.Equal(t, digest.Canonical, algorithm) + assert.Equal(t, "sha256", algorithm.String()) +} + +func TestTmpSetDigestForNewObjects(t *testing.T) { + tests := []struct { + name string + algorithm digest.Algorithm + expectError bool + expected digest.Algorithm + }{ + { + name: "Set SHA256", + algorithm: digest.SHA256, + expectError: false, + expected: digest.SHA256, + }, + { + name: "Set SHA512", + algorithm: digest.SHA512, + expectError: false, + expected: digest.SHA512, + }, + { + name: "Set empty string (should default to SHA256)", + algorithm: "", + expectError: false, + expected: digest.Canonical, // SHA256 + }, + { + name: "Set unsupported algorithm SHA384", + algorithm: digest.SHA384, + expectError: true, + expected: digest.Canonical, // Should remain unchanged (default) + }, + { + name: "Set unsupported algorithm MD5", + algorithm: digest.Digest("md5:invalid").Algorithm(), + expectError: true, + expected: digest.Canonical, // Should remain unchanged (default) + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := TmpSetDigestForNewObjects(tt.algorithm) + if tt.expectError { + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported digest algorithm") + // Verify algorithm wasn't changed + assert.Equal(t, tt.expected, TmpDigestForNewObjects()) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expected, TmpDigestForNewObjects()) + } + }) + } +} + +func TestAlgorithmPersistence(t *testing.T) { + // Test that algorithm changes persist across multiple calls + err := TmpSetDigestForNewObjects(digest.SHA512) + require.NoError(t, err) + assert.Equal(t, digest.SHA512, TmpDigestForNewObjects()) + + // Verify it's still SHA512 after another call + assert.Equal(t, digest.SHA512, TmpDigestForNewObjects()) + + // Change to SHA256 + err = TmpSetDigestForNewObjects(digest.SHA256) + require.NoError(t, err) + assert.Equal(t, digest.SHA256, TmpDigestForNewObjects()) + + // Verify it's still SHA256 after another call + assert.Equal(t, digest.SHA256, TmpDigestForNewObjects()) +} + +func TestAlgorithmStringRepresentation(t *testing.T) { + // Test SHA256 string representation + err := TmpSetDigestForNewObjects(digest.SHA256) + require.NoError(t, err) + assert.Equal(t, "sha256", TmpDigestForNewObjects().String()) + + // Test SHA512 string representation + err = TmpSetDigestForNewObjects(digest.SHA512) + require.NoError(t, err) + assert.Equal(t, "sha512", TmpDigestForNewObjects().String()) +} + +func TestAlgorithmConcurrency(t *testing.T) { + // Test concurrent reads and writes to ensure thread safety + const numReaders = 10 + const numWriters = 10 + + var wg sync.WaitGroup + errCh := make(chan error, numWriters) + readResults := make(chan digest.Algorithm, numReaders) + + // Start reader goroutines + for i := 0; i < numReaders; i++ { + wg.Add(1) + go func() { + defer wg.Done() + algorithm := TmpDigestForNewObjects() // Read operation + readResults <- algorithm + }() + } + + // Start writer goroutines - all writing the same algorithm + for i := 0; i < numWriters; i++ { + wg.Add(1) + go func() { + defer wg.Done() + err := TmpSetDigestForNewObjects(digest.SHA512) // All writers set SHA512 + if err != nil { + errCh <- err + } + }() + } + + // Wait for all goroutines to complete + wg.Wait() + close(errCh) + close(readResults) + + // Check for any errors + for err := range errCh { + assert.NoError(t, err) + } + + // Verify all readers got a valid algorithm (either SHA256 or SHA512) + for algorithm := range readResults { + assert.Contains(t, []digest.Algorithm{digest.SHA256, digest.SHA512}, algorithm) + } + + // Final check - should be SHA512 since all writers set it + finalAlgorithm := TmpDigestForNewObjects() + assert.Equal(t, digest.SHA512, finalAlgorithm) +} + +func TestIsSupportedDigestAlgorithm(t *testing.T) { + tests := []struct { + name string + algorithm digest.Algorithm + expected bool + }{ + {"SHA256", digest.SHA256, true}, + {"SHA512", digest.SHA512, true}, + {"Canonical", digest.Canonical, true}, + {"Empty string", "", true}, + {"SHA384", digest.SHA384, false}, + {"MD5", digest.Digest("md5:invalid").Algorithm(), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := IsSupportedDigestAlgorithm(tt.algorithm) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestGetSupportedDigestAlgorithms(t *testing.T) { + algorithms := GetSupportedDigestAlgorithms() + expected := []digest.Algorithm{digest.SHA256, digest.SHA512} + assert.Equal(t, expected, algorithms) +} + +func TestGetDigestAlgorithmName(t *testing.T) { + tests := []struct { + name string + algorithm digest.Algorithm + expected string + }{ + {"SHA256", digest.SHA256, "SHA256"}, + {"SHA512", digest.SHA512, "SHA512"}, + {"Canonical", digest.Canonical, "SHA256"}, // Canonical is SHA256 + {"Unknown", digest.Digest("unknown:invalid").Algorithm(), "unknown"}, + // Case-insensitive tests + {"sha256 lowercase", digest.Algorithm("sha256"), "SHA256"}, + {"SHA256 uppercase", digest.Algorithm("SHA256"), "SHA256"}, + {"Sha256 mixed case", digest.Algorithm("Sha256"), "SHA256"}, + {"sHa256 mixed case", digest.Algorithm("sHa256"), "SHA256"}, + {"sha512 lowercase", digest.Algorithm("sha512"), "SHA512"}, + {"SHA512 uppercase", digest.Algorithm("SHA512"), "SHA512"}, + {"Sha512 mixed case", digest.Algorithm("Sha512"), "SHA512"}, + {"sHa512 mixed case", digest.Algorithm("sHa512"), "SHA512"}, + {"Unknown mixed case", digest.Algorithm("Unknown"), "unknown"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GetDigestAlgorithmName(tt.algorithm) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestGetDigestAlgorithmExpectedLength(t *testing.T) { + tests := []struct { + name string + algorithm digest.Algorithm + expectedLength int + expectedFound bool + }{ + {"SHA256", digest.SHA256, 64, true}, + {"SHA512", digest.SHA512, 128, true}, + {"Canonical", digest.Canonical, 64, true}, // Canonical is SHA256 + {"Empty string", "", 0, false}, + {"SHA384", digest.SHA384, 0, false}, + {"MD5", digest.Digest("md5:invalid").Algorithm(), 0, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + length, found := GetDigestAlgorithmExpectedLength(tt.algorithm) + assert.Equal(t, tt.expectedLength, length) + assert.Equal(t, tt.expectedFound, found) + }) + } +} + +func TestDetectDigestAlgorithmFromLength(t *testing.T) { + tests := []struct { + name string + length int + expectedAlg digest.Algorithm + expectedFound bool + }{ + {"SHA256 length", 64, digest.SHA256, true}, + {"SHA512 length", 128, digest.SHA512, true}, + {"Invalid length 32", 32, digest.Algorithm(""), false}, + {"Invalid length 96", 96, digest.Algorithm(""), false}, + {"Invalid length 0", 0, digest.Algorithm(""), false}, + {"Invalid length -1", -1, digest.Algorithm(""), false}, + {"Invalid length 256", 256, digest.Algorithm(""), false}, + {"Edge case length 63", 63, digest.Algorithm(""), false}, + {"Edge case length 65", 65, digest.Algorithm(""), false}, + {"Edge case length 127", 127, digest.Algorithm(""), false}, + {"Edge case length 129", 129, digest.Algorithm(""), false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + algorithm, found := DetectDigestAlgorithmFromLength(tt.length) + assert.Equal(t, tt.expectedFound, found) + if tt.expectedFound { + assert.Equal(t, tt.expectedAlg, algorithm) + } else { + assert.Equal(t, digest.Algorithm(""), algorithm) + } + }) + } +} + +func TestDetectDigestAlgorithmFromLengthConsistency(t *testing.T) { + // Test that DetectDigestAlgorithmFromLength is consistent with GetDigestAlgorithmExpectedLength + supportedAlgorithms := GetSupportedDigestAlgorithms() + + for _, algorithm := range supportedAlgorithms { + expectedLength, supported := GetDigestAlgorithmExpectedLength(algorithm) + if supported { + detectedAlgorithm, found := DetectDigestAlgorithmFromLength(expectedLength) + assert.True(t, found, "Should detect algorithm %s for length %d", algorithm.String(), expectedLength) + assert.Equal(t, algorithm, detectedAlgorithm, "Detected algorithm should match expected algorithm") + } + } +} + +func TestDetectDigestAlgorithmFromLengthAllSupportedLengths(t *testing.T) { + // Test all supported lengths to ensure they are detected correctly + expectedLengths := []int{64, 128} // SHA256 and SHA512 lengths + + for _, length := range expectedLengths { + algorithm, found := DetectDigestAlgorithmFromLength(length) + assert.True(t, found, "Should detect algorithm for length %d", length) + assert.NotEqual(t, digest.Algorithm(""), algorithm, "Should return a valid algorithm for length %d", length) + + // Verify the detected algorithm has the expected length + expectedLength, supported := GetDigestAlgorithmExpectedLength(algorithm) + assert.True(t, supported, "Detected algorithm should be supported") + assert.Equal(t, length, expectedLength, "Detected algorithm should have the expected length") + } +}