move artifact package over

Signed-off-by: Meredith Lancaster <malancas@github.com>
This commit is contained in:
Meredith Lancaster 2024-03-01 16:16:20 -07:00
parent 3a08c03cc7
commit 97c10f4c04
10 changed files with 525 additions and 1 deletions

View file

@ -0,0 +1,74 @@
package artifact
import (
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
"github.com/github/gh-attestation/pkg/artifact/oci"
)
type artifactType int
const (
ociArtifactType artifactType = iota
fileArtifactType
)
// DigestedArtifact abstracts the software artifact being verified
type DigestedArtifact struct {
URL string
digest string
digestAlg string
}
func normalizeReference(reference string, pathSeparator rune) (normalized string, artifactType artifactType, err error) {
switch {
case strings.HasPrefix(reference, "oci://"):
return reference[6:], ociArtifactType, nil
case strings.HasPrefix(reference, "file://"):
uri, err := url.ParseRequestURI(reference)
if err != nil {
return "", 0, fmt.Errorf("failed to parse reference URI: %w", err)
}
var path string
if pathSeparator == '/' {
// Unix paths use forward slashes like URIs, so no need to modify
path = uri.Path
} else {
// Windows paths should be normalized to use backslashes
path = strings.ReplaceAll(uri.Path, "/", string(pathSeparator))
// Remove leading slash from Windows paths if present
if strings.HasPrefix(path, string(pathSeparator)) {
path = path[1:]
}
}
return filepath.Clean(path), fileArtifactType, nil
}
// Treat any other reference as a local file path
return filepath.Clean(reference), fileArtifactType, nil
}
func NewDigestedArtifact(client oci.Client, reference, digestAlg string) (artifact *DigestedArtifact, err error) {
normalized, artifactType, err := normalizeReference(reference, os.PathSeparator)
if err != nil {
return nil, err
}
if artifactType == ociArtifactType {
// TODO: should we allow custom digestAlg for OCI artifacts?
return digestContainerImageArtifact(normalized, client)
}
return digestLocalFileArtifact(normalized, digestAlg)
}
// Digest returns the digest of the artifact
func (a *DigestedArtifact) Digest() string {
return a.digest
}
// DigestWithAlg returns the digest:algorithm of the artifact
func (a *DigestedArtifact) DigestWithAlg() string {
return fmt.Sprintf("%s:%s", a.digestAlg, a.digest)
}

View file

@ -0,0 +1,99 @@
//go:build !windows
// +build !windows
package artifact
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNormalizeReference(t *testing.T) {
testCases := []struct {
name string
reference string
pathSeparator rune
expectedResult string
expectedType artifactType
expectedError bool
}{
{
name: "file reference without scheme",
reference: "/path/to/file",
pathSeparator: '/',
expectedResult: "/path/to/file",
expectedType: fileArtifactType,
expectedError: false,
},
{
name: "file scheme uri with %20",
reference: "file:///path/to/file%20with%20spaces",
pathSeparator: '/',
expectedResult: "/path/to/file with spaces",
expectedType: fileArtifactType,
expectedError: false,
},
{
name: "windows file reference without scheme",
reference: `c:\path\to\file`,
pathSeparator: '\\',
expectedResult: `c:\path\to\file`,
expectedType: fileArtifactType,
expectedError: false,
},
{
name: "file reference with scheme",
reference: "file:///path/to/file",
pathSeparator: '/',
expectedResult: "/path/to/file",
expectedType: fileArtifactType,
expectedError: false,
},
{
name: "windows path",
reference: "file:///C:/path/to/file",
pathSeparator: '\\',
expectedResult: `C:\path\to\file`,
expectedType: fileArtifactType,
expectedError: false,
},
{
name: "windows path with backslashes",
reference: "file:///C:\\path\\to\\file",
pathSeparator: '\\',
expectedResult: `C:\path\to\file`,
expectedType: fileArtifactType,
expectedError: false,
},
{
name: "oci reference",
reference: "oci://example.com/repo:tag",
pathSeparator: '/',
expectedResult: "example.com/repo:tag",
expectedType: ociArtifactType,
expectedError: false,
},
{
name: "oci reference with digest",
reference: "oci://example.com/repo@sha256:abcdef1234567890",
pathSeparator: '/',
expectedResult: "example.com/repo@sha256:abcdef1234567890",
expectedType: ociArtifactType,
expectedError: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result, artifactType, err := normalizeReference(tc.reference, tc.pathSeparator)
if tc.expectedError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tc.expectedResult, result)
assert.Equal(t, tc.expectedType, artifactType)
}
})
}
}

View file

@ -0,0 +1,59 @@
//go:build windows
// +build windows
package artifact
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNormalizeReference(t *testing.T) {
testCases := []struct {
name string
reference string
pathSeparator rune
expectedResult string
expectedType artifactType
expectedError bool
}{
{
name: "windows file reference without scheme",
reference: `c:\path\to\file`,
pathSeparator: '\\',
expectedResult: `c:\path\to\file`,
expectedType: fileArtifactType,
expectedError: false,
},
{
name: "windows path",
reference: "file:///C:/path/to/file",
pathSeparator: '\\',
expectedResult: `C:\path\to\file`,
expectedType: fileArtifactType,
expectedError: false,
},
{
name: "windows path with backslashes",
reference: "file:///C:\\path\\to\\file",
pathSeparator: '\\',
expectedResult: `C:\path\to\file`,
expectedType: fileArtifactType,
expectedError: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result, artifactType, err := normalizeReference(tc.reference, tc.pathSeparator)
if tc.expectedError {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tc.expectedResult, result)
assert.Equal(t, tc.expectedType, artifactType)
}
})
}
}

View file

@ -0,0 +1,50 @@
package digest
import (
"crypto/sha256"
"crypto/sha512"
"encoding/hex"
"fmt"
"hash"
"io"
)
const (
SHA256DigestAlgorithm = "sha256"
SHA512DigestAlgorithm = "sha512"
)
var validDigestAlgorithms = [...]string{SHA256DigestAlgorithm, SHA512DigestAlgorithm}
// IsValidDigestAlgorithm returns true if the provided algorithm is supported
func IsValidDigestAlgorithm(alg string) bool {
for _, a := range validDigestAlgorithms {
if a == alg {
return true
}
}
return false
}
// ValidDigestAlgorithms returns a list of supported digest algorithms
func ValidDigestAlgorithms() []string {
return validDigestAlgorithms[:]
}
func CalculateDigestWithAlgorithm(r io.Reader, alg string) (string, error) {
var h hash.Hash
switch alg {
case SHA256DigestAlgorithm:
h = sha256.New()
case SHA512DigestAlgorithm:
h = sha512.New()
default:
h = sha256.New()
}
if _, err := io.Copy(h, r); err != nil {
return "", fmt.Errorf("failed to calculate digest: %w", err)
}
digest := h.Sum(nil)
return hex.EncodeToString(digest[:]), nil
}

View file

@ -0,0 +1,38 @@
package digest
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestArtifactDigestWithAlgorithm(t *testing.T) {
testString := "deadbeef"
sha512TestDigest := "113a3bc783d851fc0373214b19ea7be9fa3de541ecb9fe026d52c603e8ea19c174cc0e9705f8b90d312212c0c3a6d8453ddfb3e3141409cf4bedc8ef033590b4"
sha256TestDigest := "2baf1f40105d9501fe319a8ec463fdf4325a2a5df445adf3f572f626253678c9"
t.Run("sha256", func(t *testing.T) {
reader := strings.NewReader(testString)
digest, err := CalculateDigestWithAlgorithm(reader, "sha256")
assert.Nil(t, err)
assert.Equal(t, sha256TestDigest, digest)
})
t.Run("sha512", func(t *testing.T) {
reader := strings.NewReader(testString)
digest, err := CalculateDigestWithAlgorithm(reader, "sha512")
assert.Nil(t, err)
assert.Equal(t, sha512TestDigest, digest)
})
}
func TestValidDigestAlgorithms(t *testing.T) {
t.Run("includes sha256", func(t *testing.T) {
assert.Contains(t, ValidDigestAlgorithms(), "sha256")
})
t.Run("includes sha512", func(t *testing.T) {
assert.Contains(t, ValidDigestAlgorithms(), "sha512")
})
}

View file

@ -0,0 +1,25 @@
package artifact
import (
"fmt"
"os"
"github.com/github/gh-attestation/pkg/artifact/digest"
)
func digestLocalFileArtifact(filename, digestAlg string) (*DigestedArtifact, error) {
data, err := os.Open(filename)
if err != nil {
return nil, fmt.Errorf("failed to get open local artifact: %w", err)
}
defer data.Close()
digest, err := digest.CalculateDigestWithAlgorithm(data, digestAlg)
if err != nil {
return nil, fmt.Errorf("failed to calculate local artifact digest: %w", err)
}
return &DigestedArtifact{
URL: fmt.Sprintf("file://%s", filename),
digest: digest,
digestAlg: digestAlg,
}, nil
}

View file

@ -0,0 +1,28 @@
package artifact
import (
"fmt"
"github.com/distribution/reference"
"github.com/github/gh-attestation/pkg/artifact/oci"
)
func digestContainerImageArtifact(url string, client oci.Client) (*DigestedArtifact, error) {
// try to parse the url as a valid registry reference
named, err := reference.Parse(url)
if err != nil {
// cannot be parsed as a registry reference
return nil, fmt.Errorf("artifact %s is not a valid registry reference", url)
}
digest, err := client.GetImageDigest(named.String())
if err != nil {
return nil, err
}
return &DigestedArtifact{
URL: fmt.Sprintf("oci://%s", named.String()),
digest: digest.Hex,
digestAlg: digest.Algorithm,
}, nil
}

View file

@ -0,0 +1,41 @@
package artifact
import (
"fmt"
"testing"
"github.com/github/gh-attestation/pkg/artifact/oci"
"github.com/stretchr/testify/assert"
)
func TestDigestContainerImageArtifact(t *testing.T) {
expectedDigest := "1234567890abcdef"
client := oci.NewMockClient()
url := "example.com/repo:tag"
digestedArtifact, err := digestContainerImageArtifact(url, client)
assert.NoError(t, err)
assert.Equal(t, fmt.Sprintf("oci://%s", url), digestedArtifact.URL)
assert.Equal(t, expectedDigest, digestedArtifact.digest)
assert.Equal(t, "sha256", digestedArtifact.digestAlg)
}
func TestFetchImageFailure(t *testing.T) {
client := oci.NewReferenceFailClient()
url := "example.com/repo:tag"
_, err := digestContainerImageArtifact(url, client)
assert.Error(t, err)
}
func TestRegistryAuthFailure(t *testing.T) {
client := oci.NewAuthFailClient()
url := "example.com/repo:tag"
_, err := digestContainerImageArtifact(url, client)
assert.ErrorIs(t, err, oci.ErrRegistryAuthz)
}
func TestDeniedFailure(t *testing.T) {
client := oci.NewDeniedClient()
url := "example.com/repo:tag"
_, err := digestContainerImageArtifact(url, client)
assert.ErrorIs(t, err, oci.ErrDenied)
}

View file

@ -0,0 +1,110 @@
package oci
import (
"errors"
"fmt"
"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/name"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/google/go-containerregistry/pkg/v1/remote/transport"
)
var ErrDenied = errors.New("the provided token was denied access to the requested resource, please check the token's expiration and repository access")
var ErrRegistryAuthz = errors.New("remote registry authorization failed, please authenticate with the registry and try again")
type Client struct {
ParseReference func(string, ...name.Option) (name.Reference, error)
Get func(name.Reference, ...remote.Option) (*remote.Descriptor, error)
}
func checkForUnauthorizedOrDeniedErr(err transport.Error) error {
for _, diagnostic := range err.Errors {
switch diagnostic.Code {
case transport.UnauthorizedErrorCode:
return ErrRegistryAuthz
case transport.DeniedErrorCode:
return ErrDenied
}
}
return nil
}
// where name is formed like ghcr.io/github/my-image-repo
func (c Client) GetImageDigest(imgName string) (*v1.Hash, error) {
name, err := c.ParseReference(imgName)
if err != nil {
return nil, fmt.Errorf("failed to create image tag: %w", err)
}
desc, err := c.Get(name, remote.WithAuthFromKeychain(authn.DefaultKeychain))
if err != nil {
var transportErr *transport.Error
if errors.As(err, &transportErr) {
if accessErr := checkForUnauthorizedOrDeniedErr(*transportErr); accessErr != nil {
return nil, accessErr
}
}
return nil, fmt.Errorf("failed to fetch remote image: %w", err)
}
return &desc.Digest, nil
}
func NewLiveClient() Client {
return Client{
ParseReference: name.ParseReference,
Get: remote.Get,
}
}
func NewMockClient() Client {
return Client{
ParseReference: func(string, ...name.Option) (name.Reference, error) {
return name.Tag{}, nil
},
Get: func(name.Reference, ...remote.Option) (*remote.Descriptor, error) {
d := remote.Descriptor{}
d.Digest = v1.Hash{
Hex: "1234567890abcdef",
Algorithm: "sha256",
}
return &d, nil
},
}
}
func NewReferenceFailClient() Client {
return Client{
ParseReference: func(string, ...name.Option) (name.Reference, error) {
return nil, fmt.Errorf("failed to parse reference")
},
Get: func(name.Reference, ...remote.Option) (*remote.Descriptor, error) {
return nil, nil
},
}
}
func NewAuthFailClient() Client {
return Client{
ParseReference: func(string, ...name.Option) (name.Reference, error) {
return name.Tag{}, nil
},
Get: func(name.Reference, ...remote.Option) (*remote.Descriptor, error) {
return nil, &transport.Error{Errors: []transport.Diagnostic{{Code: transport.UnauthorizedErrorCode}}}
},
}
}
func NewDeniedClient() Client {
return Client{
ParseReference: func(string, ...name.Option) (name.Reference, error) {
return name.Tag{}, nil
},
Get: func(name.Reference, ...remote.Option) (*remote.Descriptor, error) {
return nil, &transport.Error{Errors: []transport.Diagnostic{{Code: transport.DeniedErrorCode}}}
},
}
}

View file

@ -5,7 +5,7 @@ import (
"path/filepath"
"strings"
"github.com/github/gh-attestation/pkg/artifact/digest"
"github.com/cli/cli/v2/pkg/cmd/artifact/digest"
"github.com/github/gh-attestation/pkg/artifact/oci"
"github.com/github/gh-attestation/pkg/github"
"github.com/github/gh-attestation/pkg/output"