diff --git a/pkg/attestation/crafter/materials/oci_image.go b/pkg/attestation/crafter/materials/oci_image.go index b26f77a65..8b3e71df9 100644 --- a/pkg/attestation/crafter/materials/oci_image.go +++ b/pkg/attestation/crafter/materials/oci_image.go @@ -1,5 +1,5 @@ // -// Copyright 2024 The Chainloop Authors. +// Copyright 2024-2025 The Chainloop Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -18,7 +18,10 @@ package materials import ( "context" "encoding/base64" + "encoding/json" "fmt" + "os" + "path/filepath" "strings" "sync" @@ -29,6 +32,7 @@ import ( "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/layout" "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/rs/zerolog" cosigntypes "github.com/sigstore/cosign/v2/pkg/types" @@ -43,6 +47,8 @@ const ( notarySignatureMimeType = "application/vnd.cncf.notary.signature" // latestTag is the tag name for the latest image. latestTag = "latest" + // ociLayoutRepoName is the default repository name for OCI layout images. + ociLayoutRepoName = "oci-layout" ) // signatureProvider is the type for the signature provider of a container image. @@ -83,7 +89,15 @@ func NewOCIImageCrafter(schema *schemaapi.CraftingSchema_Material, ociAuth authn return c, nil } -func (i *OCIImageCrafter) Craft(_ context.Context, imageRef string) (*api.Attestation_Material, error) { +func (i *OCIImageCrafter) Craft(ctx context.Context, imageRef string) (*api.Attestation_Material, error) { + // Check if imageRef is a path to an OCI layout directory + layoutPath, digestSelector := parseLayoutReference(imageRef) + if i.isOCILayoutPath(layoutPath) { + i.logger.Debug().Str("path", layoutPath).Str("digest", digestSelector).Msg("detected OCI layout directory") + return i.craftFromLayout(ctx, layoutPath, digestSelector) + } + + // Otherwise, treat as remote registry reference i.logger.Debug().Str("name", imageRef).Msg("retrieving container image digest from remote") ref, err := name.ParseReference(imageRef) @@ -281,3 +295,205 @@ func (i *OCIImageCrafter) isLatestTag(ref name.Reference, currentDigest string) i.logger.Debug().Str("name", latestRef.String()).Msg("image does not have a 'latest' tag") return false } + +// parseLayoutReference parses a layout reference that may include a digest selector. +func parseLayoutReference(ref string) (string, string) { + // Check for @digest suffix + if idx := strings.LastIndex(ref, "@"); idx != -1 { + return ref[:idx], ref[idx+1:] + } + return ref, "" +} + +// isOCILayoutPath checks if the given path is a valid OCI layout directory. +func (i *OCIImageCrafter) isOCILayoutPath(path string) bool { + // Check if path exists and is a directory + info, err := os.Stat(path) + if err != nil || !info.IsDir() { + return false + } + + // Check for oci-layout file + layoutFile := filepath.Join(path, ociLayoutRepoName) + if _, err := os.Stat(layoutFile); err != nil { + return false + } + + return true +} + +// craftFromLayout creates a material from an OCI layout directory. +// If digestSelector is provided, it will look for that specific digest in the layout. +// Otherwise, it uses the first manifest in the index. +func (i *OCIImageCrafter) craftFromLayout(_ context.Context, layoutPath, digestSelector string) (*api.Attestation_Material, error) { + // Read the OCI layout + layoutPath, err := filepath.Abs(layoutPath) + if err != nil { + return nil, fmt.Errorf("failed to get absolute path: %w", err) + } + + path, err := layout.FromPath(layoutPath) + if err != nil { + return nil, fmt.Errorf("failed to read OCI layout: %w", err) + } + + // Get the image index + index, err := path.ImageIndex() + if err != nil { + return nil, fmt.Errorf("failed to read image index: %w", err) + } + + indexManifest, err := index.IndexManifest() + if err != nil { + return nil, fmt.Errorf("failed to read index manifest: %w", err) + } + + if len(indexManifest.Manifests) == 0 { + return nil, fmt.Errorf("no manifests found in OCI layout") + } + + // Select the manifest based on digest selector + // If a specific digest is requested, find it + if digestSelector != "" { + found := false + var manifest v1.Descriptor + for _, m := range indexManifest.Manifests { + if m.Digest.String() == digestSelector { + manifest = m + found = true + break + } + } + if !found { + return nil, fmt.Errorf("digest %s not found in OCI layout", digestSelector) + } + i.logger.Debug().Str("digest", digestSelector).Msg("selected image by digest") + + return i.buildMaterialFromManifest(layoutPath, manifest, indexManifest.Manifests) + } + + // No digest specified - if multiple images exist, require explicit selection + if len(indexManifest.Manifests) > 1 { + var digests []string + for _, m := range indexManifest.Manifests { + digests = append(digests, m.Digest.String()) + } + return nil, fmt.Errorf("OCI layout contains %d images, please specify which one to use with @digest. Available digests: %s", + len(indexManifest.Manifests), strings.Join(digests, ", ")) + } + + // Only one image, safe to use it + manifest := indexManifest.Manifests[0] + i.logger.Debug().Msg("using only image in layout") + + return i.buildMaterialFromManifest(layoutPath, manifest, indexManifest.Manifests) +} + +// buildMaterialFromManifest constructs the attestation material from a manifest descriptor. +func (i *OCIImageCrafter) buildMaterialFromManifest(layoutPath string, manifest v1.Descriptor, allManifests []v1.Descriptor) (*api.Attestation_Material, error) { + digest := manifest.Digest.String() + + // Extract repository name from annotations if available + repoName := ociLayoutRepoName + ":" + imageName := "unknown" + if manifest.Annotations != nil { + // Try annotation keys in preference order + for _, key := range []string{ + "org.opencontainers.image.ref.name", + "org.opencontainers.image.base.name", + } { + if name, ok := manifest.Annotations[key]; ok { + imageName = name + break + } + } + } + repoName += imageName + + // Extract tag from annotations + tag := "" + if manifest.Annotations != nil { + if t, ok := manifest.Annotations["io.containerd.image.name"]; ok { + // Extract tag from full reference (e.g., "registry/repo:tag" -> "tag") + parts := strings.Split(t, ":") + if len(parts) > 1 { + tag = parts[len(parts)-1] + } + } + } + + // Validate artifact type if specified + if i.artifactTypeValidation != "" { + i.logger.Debug().Str("path", layoutPath).Str("want", i.artifactTypeValidation).Msg("validating artifact type") + if manifest.ArtifactType != i.artifactTypeValidation { + return nil, fmt.Errorf("artifact type %s does not match expected type %s", manifest.ArtifactType, i.artifactTypeValidation) + } + } + + i.logger.Debug().Str("path", layoutPath).Str("digest", digest).Msg("OCI layout image resolved") + + // Check for signatures in the layout + signatureInfo := i.checkForSignatureInLayout(allManifests, digest) + + containerImage := &api.Attestation_Material_ContainerImage{ + Id: i.input.Name, + Name: repoName, + Digest: digest, + IsSubject: i.input.Output, + Tag: tag, + } + + // Add signature information if found + if signatureInfo != nil { + containerImage.SignatureDigest = signatureInfo.digest + containerImage.Signature = signatureInfo.payload + containerImage.SignatureProvider = string(signatureInfo.provider) + } + + return &api.Attestation_Material{ + MaterialType: i.input.Type, + M: &api.Attestation_Material_ContainerImage_{ + ContainerImage: containerImage}, + }, nil +} + +// checkForSignatureInLayout checks for signatures in the OCI layout manifests. +func (i *OCIImageCrafter) checkForSignatureInLayout(manifests []v1.Descriptor, imageDigest string) *containerSignatureInfo { + // Look for signature artifacts that reference the image digest + for _, m := range manifests { + // Check if this manifest references our image + if m.Annotations != nil { + if subject, ok := m.Annotations["org.opencontainers.image.base.digest"]; ok && subject == imageDigest { + // Check for Cosign signature + if m.ArtifactType == cosigntypes.SimpleSigningMediaType { + i.logger.Debug().Str("digest", m.Digest.String()).Msg("found Cosign signature artifact in OCI layout") + return i.encodeLayoutSignature(m, cosignSignatureProvider) + } + // Check for Notary signature + if m.ArtifactType == notarySignatureMimeType { + i.logger.Debug().Str("digest", m.Digest.String()).Msg("found Notary signature artifact in OCI layout") + return i.encodeLayoutSignature(m, notarySignatureProvider) + } + } + } + } + + i.logger.Debug().Str("digest", imageDigest).Msg("no signature found in OCI layout") + return nil +} + +// encodeLayoutSignature encodes a signature descriptor as base64. +func (i *OCIImageCrafter) encodeLayoutSignature(desc v1.Descriptor, provider signatureProvider) *containerSignatureInfo { + // Marshal the descriptor to JSON for the payload + manifestBytes, err := json.Marshal(desc) + if err != nil { + i.logger.Debug().Err(err).Msg("failed to marshal signature descriptor") + return nil + } + + return &containerSignatureInfo{ + digest: desc.Digest.String(), + provider: provider, + payload: base64.StdEncoding.EncodeToString(manifestBytes), + } +} diff --git a/pkg/attestation/crafter/materials/oci_image_test.go b/pkg/attestation/crafter/materials/oci_image_test.go new file mode 100644 index 000000000..cef29b411 --- /dev/null +++ b/pkg/attestation/crafter/materials/oci_image_test.go @@ -0,0 +1,217 @@ +// +// Copyright 2025 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package materials_test + +import ( + "context" + "testing" + + contractAPI "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" + "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewOCIImageCrafter(t *testing.T) { + testCases := []struct { + name string + input *contractAPI.CraftingSchema_Material + wantErr bool + }{ + { + name: "container image type", + input: &contractAPI.CraftingSchema_Material{ + Type: contractAPI.CraftingSchema_Material_CONTAINER_IMAGE, + }, + }, + { + name: "helm chart type", + input: &contractAPI.CraftingSchema_Material{ + Type: contractAPI.CraftingSchema_Material_HELM_CHART, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + l := zerolog.Nop() + _, err := materials.NewOCIImageCrafter(tc.input, nil, &l) + if tc.wantErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + }) + } +} + +func TestOCIImageCraft_Layout(t *testing.T) { + testCases := []struct { + name string + layoutPath string + wantErr string + wantDigest string + wantName string + wantTag string + }{ + { + name: "crane - single image with annotations", + layoutPath: "testdata/oci-layouts/crane", + wantName: "oci-layout:unknown", + wantDigest: "sha256:fa6d9058c3d65a33ff565c0e35172f2d99e76fbf8358d91ffaa2208eff2be400", + }, + { + name: "skopeo - single image with tag annotation", + layoutPath: "testdata/oci-layouts/skopeo", + wantName: "oci-layout:v1.51.0", + wantDigest: "sha256:fa6d9058c3d65a33ff565c0e35172f2d99e76fbf8358d91ffaa2208eff2be400", + }, + { + name: "skopeo-alt - alternative format", + layoutPath: "testdata/oci-layouts/skopeo-alt", + wantName: "oci-layout:v1.51.0", + wantDigest: "sha256:a5303ef28a4bd9b6e06aa92c07831dd151ac64172695971226bdba4a11fc1b88", + }, + { + name: "non-existent path", + layoutPath: "/non/existent/path", + wantErr: "UNAUTHORIZED", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + schema := &contractAPI.CraftingSchema_Material{ + Name: "test", + Type: contractAPI.CraftingSchema_Material_CONTAINER_IMAGE, + } + l := zerolog.Nop() + crafter, err := materials.NewOCIImageCrafter(schema, nil, &l) + require.NoError(t, err) + + got, err := crafter.Craft(context.TODO(), tc.layoutPath) + if tc.wantErr != "" { + assert.ErrorContains(t, err, tc.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, contractAPI.CraftingSchema_Material_CONTAINER_IMAGE.String(), got.MaterialType.String()) + + // Check container image fields + containerImage := got.GetContainerImage() + require.NotNil(t, containerImage) + assert.Equal(t, tc.wantName, containerImage.Name) + if tc.wantTag != "" { + assert.Equal(t, tc.wantTag, containerImage.Tag) + } + if tc.wantDigest != "" { + assert.Equal(t, tc.wantDigest, containerImage.Digest) + } else { + assert.NotEmpty(t, containerImage.Digest) + } + }) + } +} + +func TestOCIImageCraft_LayoutWithDigestSelector(t *testing.T) { + testCases := []struct { + name string + layoutPath string + digestSelector string + wantErr string + wantName string + wantDigest string + }{ + { + name: "oras - select first image by digest", + layoutPath: "testdata/oci-layouts/oras", + digestSelector: "sha256:b1747c197a0ab3cb89e109f60a3c5d4ede6946e447fd468fa82d85fa94c6c6e5", + wantName: "oci-layout:unknown", + wantDigest: "sha256:b1747c197a0ab3cb89e109f60a3c5d4ede6946e447fd468fa82d85fa94c6c6e5", + }, + { + name: "oras - select second image by digest", + layoutPath: "testdata/oci-layouts/oras", + digestSelector: "sha256:f333056ac987169b2a121c16d06112d88ec3d7cb50b098bb17b0f14b0c52f6f3", + wantName: "oci-layout:unknown", + wantDigest: "sha256:f333056ac987169b2a121c16d06112d88ec3d7cb50b098bb17b0f14b0c52f6f3", + }, + { + name: "zarf - select specific image from bundle", + layoutPath: "testdata/oci-layouts/zarf", + digestSelector: "sha256:e8ac056f7b9b44b07935fe23b8383e5e550d479dc5c6261941e76449a8f7e926", + wantName: "oci-layout:ghcr.io/chainloop-dev/chainloop/artifact-cas:v1.51.0", + wantDigest: "sha256:e8ac056f7b9b44b07935fe23b8383e5e550d479dc5c6261941e76449a8f7e926", + }, + { + name: "digest not found", + layoutPath: "testdata/oci-layouts/oras", + digestSelector: "sha256:nonexistent", + wantErr: "not found in OCI layout", + }, + { + name: "oras - multiple images without digest selector", + layoutPath: "testdata/oci-layouts/oras", + wantErr: "contains 3 images, please specify which one", + }, + { + name: "zarf - multiple images without digest selector", + layoutPath: "testdata/oci-layouts/zarf", + wantErr: "contains 3 images, please specify which one", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + imageRef := tc.layoutPath + if tc.digestSelector != "" { + imageRef = tc.layoutPath + "@" + tc.digestSelector + } + + schema := &contractAPI.CraftingSchema_Material{ + Name: "test", + Type: contractAPI.CraftingSchema_Material_CONTAINER_IMAGE, + } + l := zerolog.Nop() + crafter, err := materials.NewOCIImageCrafter(schema, nil, &l) + require.NoError(t, err) + + got, err := crafter.Craft(context.TODO(), imageRef) + if tc.wantErr != "" { + assert.ErrorContains(t, err, tc.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, contractAPI.CraftingSchema_Material_CONTAINER_IMAGE.String(), got.MaterialType.String()) + + // Check container image fields + containerImage := got.GetContainerImage() + require.NotNil(t, containerImage) + if tc.wantName != "" { + assert.Equal(t, tc.wantName, containerImage.Name) + } + if tc.wantDigest != "" { + assert.Equal(t, tc.wantDigest, containerImage.Digest) + } else { + assert.NotEmpty(t, containerImage.Digest) + } + }) + } +} diff --git a/pkg/attestation/crafter/materials/testdata/oci-layouts/crane/index.json b/pkg/attestation/crafter/materials/testdata/oci-layouts/crane/index.json new file mode 100755 index 000000000..9ccaf78b2 --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/oci-layouts/crane/index.json @@ -0,0 +1,11 @@ +{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.index.v1+json", + "manifests": [ + { + "mediaType": "application/vnd.docker.distribution.manifest.list.v2+json", + "size": 743, + "digest": "sha256:fa6d9058c3d65a33ff565c0e35172f2d99e76fbf8358d91ffaa2208eff2be400" + } + ] +} \ No newline at end of file diff --git a/pkg/attestation/crafter/materials/testdata/oci-layouts/crane/oci-layout b/pkg/attestation/crafter/materials/testdata/oci-layouts/crane/oci-layout new file mode 100755 index 000000000..224a86981 --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/oci-layouts/crane/oci-layout @@ -0,0 +1,3 @@ +{ + "imageLayoutVersion": "1.0.0" +} \ No newline at end of file diff --git a/pkg/attestation/crafter/materials/testdata/oci-layouts/oras/index.json b/pkg/attestation/crafter/materials/testdata/oci-layouts/oras/index.json new file mode 100644 index 000000000..0d590ca51 --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/oci-layouts/oras/index.json @@ -0,0 +1 @@ +{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[{"mediaType":"application/vnd.docker.distribution.manifest.v2+json","digest":"sha256:b1747c197a0ab3cb89e109f60a3c5d4ede6946e447fd468fa82d85fa94c6c6e5","size":1578,"platform":{"architecture":"arm64","os":"linux"}},{"mediaType":"application/vnd.docker.distribution.manifest.v2+json","digest":"sha256:f333056ac987169b2a121c16d06112d88ec3d7cb50b098bb17b0f14b0c52f6f3","size":1578,"platform":{"architecture":"amd64","os":"linux"}},{"mediaType":"application/vnd.docker.distribution.manifest.list.v2+json","digest":"sha256:fa6d9058c3d65a33ff565c0e35172f2d99e76fbf8358d91ffaa2208eff2be400","size":743}]} \ No newline at end of file diff --git a/pkg/attestation/crafter/materials/testdata/oci-layouts/oras/oci-layout b/pkg/attestation/crafter/materials/testdata/oci-layouts/oras/oci-layout new file mode 100644 index 000000000..1343d370f --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/oci-layouts/oras/oci-layout @@ -0,0 +1 @@ +{"imageLayoutVersion":"1.0.0"} \ No newline at end of file diff --git a/pkg/attestation/crafter/materials/testdata/oci-layouts/skopeo-alt/index.json b/pkg/attestation/crafter/materials/testdata/oci-layouts/skopeo-alt/index.json new file mode 100644 index 000000000..9a762889c --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/oci-layouts/skopeo-alt/index.json @@ -0,0 +1 @@ +{"schemaVersion":2,"manifests":[{"mediaType":"application/vnd.oci.image.index.v1+json","digest":"sha256:a5303ef28a4bd9b6e06aa92c07831dd151ac64172695971226bdba4a11fc1b88","size":493,"annotations":{"org.opencontainers.image.ref.name":"v1.51.0"}}]} \ No newline at end of file diff --git a/pkg/attestation/crafter/materials/testdata/oci-layouts/skopeo-alt/oci-layout b/pkg/attestation/crafter/materials/testdata/oci-layouts/skopeo-alt/oci-layout new file mode 100644 index 000000000..1343d370f --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/oci-layouts/skopeo-alt/oci-layout @@ -0,0 +1 @@ +{"imageLayoutVersion":"1.0.0"} \ No newline at end of file diff --git a/pkg/attestation/crafter/materials/testdata/oci-layouts/skopeo/index.json b/pkg/attestation/crafter/materials/testdata/oci-layouts/skopeo/index.json new file mode 100644 index 000000000..77f1db87c --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/oci-layouts/skopeo/index.json @@ -0,0 +1 @@ +{"schemaVersion":2,"manifests":[{"mediaType":"application/vnd.docker.distribution.manifest.list.v2+json","digest":"sha256:fa6d9058c3d65a33ff565c0e35172f2d99e76fbf8358d91ffaa2208eff2be400","size":743,"annotations":{"org.opencontainers.image.ref.name":"v1.51.0"}}]} \ No newline at end of file diff --git a/pkg/attestation/crafter/materials/testdata/oci-layouts/skopeo/oci-layout b/pkg/attestation/crafter/materials/testdata/oci-layouts/skopeo/oci-layout new file mode 100644 index 000000000..1343d370f --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/oci-layouts/skopeo/oci-layout @@ -0,0 +1 @@ +{"imageLayoutVersion":"1.0.0"} \ No newline at end of file diff --git a/pkg/attestation/crafter/materials/testdata/oci-layouts/zarf/index.json b/pkg/attestation/crafter/materials/testdata/oci-layouts/zarf/index.json new file mode 100644 index 000000000..560cdb55b --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/oci-layouts/zarf/index.json @@ -0,0 +1 @@ +{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[{"mediaType":"application/vnd.docker.distribution.manifest.v2+json","digest":"sha256:8cb1d2d12cb680d67998ae917462779ba35ff6969ddc09fc6cdf825889bcd357","size":737,"annotations":{"org.opencontainers.image.base.name":"ghcr.io/chainloop-dev/chainloop/control-plane-migrations:v1.51.0","org.opencontainers.image.ref.name":"ghcr.io/chainloop-dev/chainloop/control-plane-migrations:v1.51.0"},"platform":{"architecture":"amd64","os":"linux"}},{"mediaType":"application/vnd.docker.distribution.manifest.v2+json","digest":"sha256:e8ac056f7b9b44b07935fe23b8383e5e550d479dc5c6261941e76449a8f7e926","size":739,"annotations":{"org.opencontainers.image.base.name":"ghcr.io/chainloop-dev/chainloop/artifact-cas:v1.51.0","org.opencontainers.image.ref.name":"ghcr.io/chainloop-dev/chainloop/artifact-cas:v1.51.0"},"platform":{"architecture":"amd64","os":"linux"}},{"mediaType":"application/vnd.docker.distribution.manifest.v2+json","digest":"sha256:f333056ac987169b2a121c16d06112d88ec3d7cb50b098bb17b0f14b0c52f6f3","size":1578,"annotations":{"org.opencontainers.image.base.name":"ghcr.io/chainloop-dev/chainloop/control-plane:v1.51.0","org.opencontainers.image.ref.name":"ghcr.io/chainloop-dev/chainloop/control-plane:v1.51.0"},"platform":{"architecture":"amd64","os":"linux"}}]} \ No newline at end of file diff --git a/pkg/attestation/crafter/materials/testdata/oci-layouts/zarf/oci-layout b/pkg/attestation/crafter/materials/testdata/oci-layouts/zarf/oci-layout new file mode 100644 index 000000000..1343d370f --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/oci-layouts/zarf/oci-layout @@ -0,0 +1 @@ +{"imageLayoutVersion":"1.0.0"} \ No newline at end of file