diff --git a/e2e/tests/up-features/up_features.go b/e2e/tests/up-features/up_features.go index fe4a103de..61dd79232 100644 --- a/e2e/tests/up-features/up_features.go +++ b/e2e/tests/up-features/up_features.go @@ -107,6 +107,100 @@ var _ = ginkgo.Describe("testing up command", ginkgo.Label("up-features", "suite framework.ExpectNoError(err) }, ginkgo.SpecTimeout(framework.GetTimeout())) + ginkgo.It( + "direct tar feature uses cached download with integrity verification", + func(ctx context.Context) { + server := ghttp.NewServer() + ginkgo.DeferCleanup(server.Close) + + tempDir1, err := framework.CopyToTempDir( + "tests/up-features/testdata/docker-features-http-headers", + ) + framework.ExpectNoError(err) + ginkgo.DeferCleanup(framework.CleanupTempDir, initialDir, tempDir1) + + // CopyToTempDir changes cwd; restore so the second copy resolves its relative path. + err = os.Chdir(initialDir) + framework.ExpectNoError(err) + + tempDir2, err := framework.CopyToTempDir( + "tests/up-features/testdata/docker-features-http-headers", + ) + framework.ExpectNoError(err) + ginkgo.DeferCleanup(framework.CleanupTempDir, initialDir, tempDir2) + + featureArchiveFilePath := path.Join(tempDir1, "devcontainer-feature-hello.tgz") + featureFiles := []string{ + path.Join(tempDir1, "devcontainer-feature.json"), + path.Join(tempDir1, "install.sh"), + } + err = createTarGzArchive(featureArchiveFilePath, featureFiles) + framework.ExpectNoError(err) + + for _, dir := range []string{tempDir1, tempDir2} { + devContainerFile := filepath.Clean(path.Join(dir, ".devcontainer.json")) + devContainerFileBuf, err := os.ReadFile(devContainerFile) + framework.ExpectNoError(err) + + output := strings.ReplaceAll( + string(devContainerFileBuf), + "#{server_url}", + server.URL(), + ) + // #nosec G306 -- test file, permissive mode is acceptable. + err = os.WriteFile(path.Join(dir, ".devcontainer.json"), []byte(output), 0o644) + framework.ExpectNoError(err) + } + + respHeader := http.Header{} + respHeader.Set( + "Content-Disposition", + "attachment; filename=devcontainer-feature-hello.tgz", + ) + + featureArchiveFileBuf, err := os.ReadFile(filepath.Clean(featureArchiveFilePath)) + framework.ExpectNoError(err) + + server.AppendHandlers( + ghttp.CombineHandlers( + ghttp.VerifyRequest("GET", "/devcontainer-feature-hello.tgz"), + ghttp.RespondWith(http.StatusOK, featureArchiveFileBuf, respHeader), + ), + ) + + f := framework.NewDefaultFramework(initialDir + "/bin") + _ = f.DevsyProviderDelete(ctx, "docker") + + err = f.DevsyProviderAdd(ctx, "docker") + framework.ExpectNoError(err) + + err = f.DevsyProviderUse(ctx, "docker") + framework.ExpectNoError(err) + + // First workspace: downloads feature, stores .sha256 sidecar + wsName1 := filepath.Base(tempDir1) + ginkgo.DeferCleanup(f.DevsyWorkspaceDelete, wsName1) + + err = f.DevsyUp(ctx, tempDir1) + framework.ExpectNoError(err) + + // Delete first workspace; feature cache persists across deletions + err = f.DevsyWorkspaceDelete(ctx, wsName1) + framework.ExpectNoError(err) + + // Second workspace: cache hit, integrity verification passes, no download + wsName2 := filepath.Base(tempDir2) + ginkgo.DeferCleanup(f.DevsyWorkspaceDelete, wsName2) + + err = f.DevsyUp(ctx, tempDir2) + framework.ExpectNoError(err) + + // Only one HTTP request was made — proves cache was reused with passing integrity + gomega.Expect(server.ReceivedRequests()).To(gomega.HaveLen(1)) + }, + ginkgo.SpecTimeout(framework.GetTimeout()), + ) + ginkgo.It("should install with lifecycle hooks", func(ctx context.Context) { f, err := setupDockerProvider(initialDir+"/bin", "docker") framework.ExpectNoError(err) diff --git a/pkg/devcontainer/feature/features.go b/pkg/devcontainer/feature/features.go index 7df768ffb..2edb67846 100644 --- a/pkg/devcontainer/feature/features.go +++ b/pkg/devcontainer/feature/features.go @@ -283,6 +283,70 @@ func downloadLayer(img v1.Image, id, destFile string) error { return writeLayerToFile(data, destFile) } +// verifyCacheIntegrity checks a cached tarball against its stored +// SHA-256 sidecar. Returns true when the cache is safe to use. +func verifyCacheIntegrity(featureFolder, id string) bool { + hashFile := filepath.Join(featureFolder, "feature.sha256") + storedBytes, err := os.ReadFile(filepath.Clean(hashFile)) + if err != nil { + log.Warnf( + "No integrity hash for cached feature (backward compat): featureId=%s", + id, + ) + return true + } + + tarball := filepath.Join(featureFolder, "feature.tgz") + computed, err := hash.File(tarball) + if err != nil { + log.Errorf("Failed to hash cached tarball: error=%v, featureId=%s", err, id) + return false + } + + if computed != strings.TrimSpace(string(storedBytes)) { + log.Errorf( + "Integrity check failed for cached feature: featureId=%s", + id, + ) + return false + } + + log.Debugf("Integrity check passed for cached feature: featureId=%s", id) + return true +} + +// storeIntegrityHash computes and persists the SHA-256 of a downloaded tarball. +func storeIntegrityHash(featureFolder, tarballPath, id string) { + computed, err := hash.File(tarballPath) + if err != nil { + log.Errorf("Failed to compute tarball hash: error=%v, featureId=%s", err, id) + return + } + + hashFile := filepath.Join(featureFolder, "feature.sha256") + if err := os.WriteFile(hashFile, []byte(computed), 0o600); err != nil { + log.Errorf("Failed to write hash sidecar: error=%v, featureId=%s", err, id) + return + } + + log.Infof("Feature tarball integrity: featureId=%s, sha256=%s", id, computed) +} + +func extractTarball(downloadFile, dest string) error { + file, err := os.Open(filepath.Clean(downloadFile)) + if err != nil { + return fmt.Errorf("open tarball: %w", err) + } + defer func() { _ = file.Close() }() + + if err := extract.Extract(file, dest); err != nil { + _ = os.RemoveAll(dest) + return fmt.Errorf("extract folder: %w", err) + } + + return nil +} + func processDirectTarFeature( id string, httpHeaders map[string]string, @@ -299,20 +363,24 @@ func processDirectTarFeature( ) } - // feature already exists? featureFolder, err := getFeaturesTempFolder(id) if err != nil { return "", fmt.Errorf("resolve feature cache dir: %w", err) } featureExtractedFolder := filepath.Join(featureFolder, "extracted") - _, err = os.Stat(featureExtractedFolder) - if err == nil && !forceDownload { - log.Debugf("direct tar feature already cached: folder=%s", featureExtractedFolder) - return featureExtractedFolder, nil + + // Check cache — verify integrity if present. + _, statErr := os.Stat(featureExtractedFolder) + if statErr == nil && !forceDownload { + if verifyCacheIntegrity(featureFolder, id) { + log.Debugf("direct tar feature already cached: folder=%s", featureExtractedFolder) + return featureExtractedFolder, nil + } + _ = os.RemoveAll(featureFolder) } - // download feature tarball + // Download feature tarball. downloadFile := filepath.Join(featureFolder, "feature.tgz") err = downloadFeatureFromURL(id, downloadFile, httpHeaders) if err != nil { @@ -320,24 +388,11 @@ func processDirectTarFeature( return "", err } - // extract file - file, err := os.Open(downloadFile) - if err != nil { - log.Errorf("failed to open downloaded tarball: error=%v, file=%s", err, downloadFile) - return "", err - } - defer func() { _ = file.Close() }() + storeIntegrityHash(featureFolder, downloadFile, id) - // extract tar.gz - err = extract.Extract(file, featureExtractedFolder) - if err != nil { - log.Errorf( - "failed to extract tarball: error=%v, destination=%s", - err, - featureExtractedFolder, - ) - _ = os.RemoveAll(featureExtractedFolder) - return "", fmt.Errorf("extract folder: %w", err) + if err := extractTarball(downloadFile, featureExtractedFolder); err != nil { + log.Errorf("failed to extract tarball: error=%v, featureId=%s", err, id) + return "", err } log.Infof( diff --git a/pkg/devcontainer/feature/features_integrity_test.go b/pkg/devcontainer/feature/features_integrity_test.go new file mode 100644 index 000000000..f1de31e73 --- /dev/null +++ b/pkg/devcontainer/feature/features_integrity_test.go @@ -0,0 +1,74 @@ +package feature + +import ( + "os" + "path/filepath" + "testing" + + "github.com/devsy-org/devsy/pkg/hash" + "github.com/stretchr/testify/suite" +) + +type IntegrityTestSuite struct { + suite.Suite +} + +func TestIntegrityTestSuite(t *testing.T) { + suite.Run(t, new(IntegrityTestSuite)) +} + +// createTestTarball writes a fake tarball file and returns its path. +func createTestTarball(dir string) (string, error) { + tarball := filepath.Join(dir, "feature.tgz") + return tarball, os.WriteFile(tarball, []byte("fake-tarball-content"), 0o600) +} + +func (s *IntegrityTestSuite) TestStoreIntegrityHash_WritesCorrectSidecar() { + dir := s.T().TempDir() + tarball, err := createTestTarball(dir) + s.Require().NoError(err) + + storeIntegrityHash(dir, tarball, "test-feature") + + hashFile := filepath.Join(dir, "feature.sha256") + stored, err := os.ReadFile(filepath.Clean(hashFile)) + s.Require().NoError(err) + + expected, err := hash.File(tarball) + s.Require().NoError(err) + s.Equal(expected, string(stored)) +} + +func (s *IntegrityTestSuite) TestVerifyCacheIntegrity_ValidHash() { + dir := s.T().TempDir() + tarball, err := createTestTarball(dir) + s.Require().NoError(err) + + computed, err := hash.File(tarball) + s.Require().NoError(err) + + hashFile := filepath.Join(dir, "feature.sha256") + s.Require().NoError(os.WriteFile(hashFile, []byte(computed), 0o600)) + + s.True(verifyCacheIntegrity(dir, "test-feature")) +} + +func (s *IntegrityTestSuite) TestVerifyCacheIntegrity_CorruptedHash() { + dir := s.T().TempDir() + _, err := createTestTarball(dir) + s.Require().NoError(err) + + hashFile := filepath.Join(dir, "feature.sha256") + s.Require().NoError(os.WriteFile(hashFile, []byte("bad-hash"), 0o600)) + + s.False(verifyCacheIntegrity(dir, "test-feature")) +} + +func (s *IntegrityTestSuite) TestVerifyCacheIntegrity_MissingHashFile() { + dir := s.T().TempDir() + _, err := createTestTarball(dir) + s.Require().NoError(err) + + // No .sha256 file — backward compat: should return true. + s.True(verifyCacheIntegrity(dir, "test-feature")) +}