Repository: ddev/ddev (go)

Issue:
Add DDEV_APPROOT web container variable equivalent of host
### Is there an existing issue for this?

- [x] I have searched the existing issues

### Is your feature request related to a problem?

There is an environment variable for the host that will give the absolute path to the project root directory. It would be great to have the equivalent in web so that we can use the variable in custom commands in the web container.

### Describe your solution

Add `DDEV_APPROOT="/var/www/html"` to environment variables for the web service. (PR incoming)

### Describe alternatives

The alternative is for the user to hard code that value in custom command files or create their own variable.

### Additional context

_No response_

Pull request description:
feat: add DDEV_APPROOT variable to web container and updates documentation, fixes #7198
<!-- 
  PR titles have very precise rules, please read 
  https://ddev.readthedocs.io/en/stable/developers/building-contributing/#pull-request-title-guidelines
-->

## The Issue

- #7198 

<!-- Provide a brief description of the issue. -->

## How This PR Solves The Issue

Adds the variable to the web container.

## Manual Testing Instructions

```
ddev ssh
echo $DDEV_APPROOT
```

You should see "/var/www/html" as the output (without the quotes of course)

## Automated Testing Overview

<!-- Please describe the tests introduced by this PR, or explain why no tests are needed. -->

## Release/Deployment Notes

<!-- Does this affect anything else or have ramifications for other code? Does anything have to be done on deployment? -->


Patch:
diff --git a/cmd/ddev/cmd/addon-get.go b/cmd/ddev/cmd/addon-get.go
index e93f61409..2ddd13bfb 100644
--- a/cmd/ddev/cmd/addon-get.go
+++ b/cmd/ddev/cmd/addon-get.go
@@ -130,10 +130,10 @@ ddev add-on get /path/to/tarball.tar.gz
 				argType = "tarball"
 			}
 			extractedDir, cleanup, err = archive.DownloadAndExtractTarball(tarballURL, true)
+			defer cleanup()
 			if err != nil {
 				util.Failed("Unable to download %v: %v", sourceRepoArg, err)
 			}
-			defer cleanup()
 		}
 
 		// 20220811: Don't auto-start because it auto-creates the wrong database in some situations, leading to a
diff --git a/cmd/ddev/cmd/config.go b/cmd/ddev/cmd/config.go
index 6b967fe96..f660cd6e2 100644
--- a/cmd/ddev/cmd/config.go
+++ b/cmd/ddev/cmd/config.go
@@ -343,6 +343,9 @@ func getConfigApp(_ string) *ddevapp.DdevApp {
 	if err != nil {
 		util.Failed("Could not create a new config: %v", err)
 	}
+	if hasConfigNameOverride, newName := app.HasConfigNameOverride(); hasConfigNameOverride {
+		app.Name = newName
+	}
 	return app
 }
 
diff --git a/docs/content/users/extend/custom-commands.md b/docs/content/users/extend/custom-commands.md
index 183f14545..3abc304af 100644
--- a/docs/content/users/extend/custom-commands.md
+++ b/docs/content/users/extend/custom-commands.md
@@ -120,6 +120,7 @@ A number of environment variables are provided to these command scripts. These a
 
 Useful variables for container scripts are:
 
+* `DDEV_APPROOT`: Absolute path to the project files within the web container
 * `DDEV_DOCROOT`: Relative path from approot to docroot
 * `DDEV_FILES_DIR`: *Deprecated*, first directory of user-uploaded files
 * `DDEV_FILES_DIRS`: Comma-separated list of directories of user-uploaded files
diff --git a/pkg/archive/archive.go b/pkg/archive/archive.go
index d19574660..78de699b9 100644
--- a/pkg/archive/archive.go
+++ b/pkg/archive/archive.go
@@ -487,7 +487,7 @@ func DownloadAndExtractTarball(url string, removeTopLevel bool) (string, func(),
 		_ = os.Remove(tarball)
 	}()
 
-	err = util.DownloadFile(tarball, url, true)
+	err = util.DownloadFile(tarball, url, true, "")
 	if err != nil {
 		return "", nil, fmt.Errorf("unable to download %v: %v", url, err)
 	}
@@ -502,12 +502,13 @@ func DownloadAndExtractTarball(url string, removeTopLevel bool) (string, func(),
 func ExtractTarballWithCleanup(tarball string, removeTopLevel bool) (string, func(), error) {
 	tmpDir, err := os.MkdirTemp("", fmt.Sprintf("ddev_%s_*", filepath.Base(tarball)))
 	if err != nil {
-		return "", nil, fmt.Errorf("unable to create temp dir: %v", err)
+		return "", func() {}, fmt.Errorf("unable to create temp dir: %v", err)
 	}
+	cleanupFunc := func() { _ = os.RemoveAll(tmpDir) }
 
 	err = Untar(tarball, tmpDir, "")
 	if err != nil {
-		return "", nil, fmt.Errorf("unable to untar %v: %v", tmpDir, err)
+		return "", cleanupFunc, fmt.Errorf("unable to untar %v: %v", tmpDir, err)
 	}
 
 	// If removeTopLevel then the guts of the tarball are the first level directory
@@ -517,13 +518,12 @@ func ExtractTarballWithCleanup(tarball string, removeTopLevel bool) (string, fun
 	if removeTopLevel {
 		list, err := fileutil.ListFilesInDir(tmpDir)
 		if err != nil {
-			return "", nil, fmt.Errorf("unable to list files in %v: %v", tmpDir, err)
+			return "", cleanupFunc, fmt.Errorf("unable to list files in %v: %v", tmpDir, err)
 		}
 		if len(list) == 0 {
-			return "", nil, fmt.Errorf("no files found in %v", tmpDir)
+			return "", cleanupFunc, fmt.Errorf("no files found in %v", tmpDir)
 		}
 		extractedDir = path.Join(tmpDir, list[0])
 	}
-	cleanupFunc := func() { _ = os.RemoveAll(tmpDir) }
 	return extractedDir, cleanupFunc, nil
 }
diff --git a/pkg/ddevapp/app_compose_template.yaml b/pkg/ddevapp/app_compose_template.yaml
index 99a535014..8e86db860 100644
--- a/pkg/ddevapp/app_compose_template.yaml
+++ b/pkg/ddevapp/app_compose_template.yaml
@@ -182,6 +182,7 @@ services:
     - COREPACK_ENABLE_DOWNLOAD_PROMPT=0
     - COREPACK_HOME=/mnt/ddev-global-cache/corepack
     - DOCROOT=${DDEV_DOCROOT}
+    - DDEV_APPROOT=/var/www/html
     - DDEV_COMPOSER_ROOT
     - DDEV_DATABASE
     - DDEV_DOCROOT
diff --git a/pkg/ddevapp/config.go b/pkg/ddevapp/config.go
index a171e6ab4..d7a37b7e3 100644
--- a/pkg/ddevapp/config.go
+++ b/pkg/ddevapp/config.go
@@ -195,6 +195,11 @@ func (app *DdevApp) WriteConfig() error {
 	// Work against a copy of the DdevApp, since we don't want to actually change it.
 	appcopy := *app
 
+	// If the app name has been changed, then remove it from the main config.yaml file.
+	if hasConfigNameOverride, _ := app.HasConfigNameOverride(); hasConfigNameOverride {
+		appcopy.Name = ""
+	}
+
 	// Only set the images on write if non-default values have been specified.
 	if appcopy.WebImage == docker.GetWebImage() {
 		appcopy.WebImage = ""
@@ -262,7 +267,7 @@ func (app *DdevApp) WriteConfig() error {
 	}
 
 	// The .ddev directory may still need to be populated, especially in tests
-	err = PopulateExamplesCommandsHomeadditions(appcopy.Name)
+	err = PopulateExamplesCommandsHomeadditions(app.Name)
 	if err != nil {
 		return err
 	}
diff --git a/pkg/ddevapp/ddevapp.go b/pkg/ddevapp/ddevapp.go
index 3b9b21585..069e9b597 100644
--- a/pkg/ddevapp/ddevapp.go
+++ b/pkg/ddevapp/ddevapp.go
@@ -81,7 +81,7 @@ type WebExtraDaemon struct {
 // DdevApp is the struct that represents a DDEV app, mostly its config
 // from config.yaml.
 type DdevApp struct {
-	Name                      string                 `yaml:"name"`
+	Name                      string                 `yaml:"name,omitempty"`
 	Type                      string                 `yaml:"type"`
 	AppRoot                   string                 `yaml:"-"`
 	Docroot                   string                 `yaml:"docroot"`
@@ -3447,6 +3447,24 @@ func (app *DdevApp) GetMinimalContainerTimeout() string {
 	return minimalTimeout
 }
 
+// HasConfigNameOverride checks if the app name should be different,
+// returns true if the app name has been changed along with the new app name.
+func (app *DdevApp) HasConfigNameOverride() (bool, string) {
+	newApp := DdevApp{ConfigPath: app.ConfigPath}
+	if _, err := newApp.ReadConfig(false); err != nil {
+		return false, app.Name
+	}
+	name := newApp.Name
+	if _, err := newApp.ReadConfig(true); err != nil {
+		return false, app.Name
+	}
+	nameWithOverrides := newApp.Name
+	if name != nameWithOverrides {
+		return true, nameWithOverrides
+	}
+	return false, app.Name
+}
+
 // genericImportFilesAction defines the workflow for importing project files.
 func genericImportFilesAction(app *DdevApp, uploadDir, importPath, extPath string) error {
 	destPath := app.calculateHostUploadDirFullPath(uploadDir)
diff --git a/pkg/ddevapp/mutagen.go b/pkg/ddevapp/mutagen.go
index 9990de438..5db8be9ba 100644
--- a/pkg/ddevapp/mutagen.go
+++ b/pkg/ddevapp/mutagen.go
@@ -526,16 +526,25 @@ func DownloadMutagen() error {
 	globalMutagenDir := filepath.Dir(globalconfig.GetMutagenPath())
 	destFile := filepath.Join(globalMutagenDir, "mutagen.tgz")
 	mutagenURL := fmt.Sprintf("https://github.com/mutagen-io/mutagen/releases/download/v%s/mutagen_%s_v%s.tar.gz", versionconstants.RequiredMutagenVersion, flavor, versionconstants.RequiredMutagenVersion)
+	shasumFileURL := fmt.Sprintf("https://github.com/mutagen-io/mutagen/releases/download/v%s/SHA256SUMS", versionconstants.RequiredMutagenVersion)
 	util.Debug("Downloading %s to %s...", mutagenURL, destFile)
 
-	// Remove the existing file. This may help on macOS to prevent the Gatekeeper's
+	// Remove the existing mutagen files. This may help on macOS to prevent the Gatekeeper's
 	// caching bug from confusing with a previously downloaded file?
 	// Discussion in https://github.com/mutagen-io/mutagen/issues/290#issuecomment-906612749
-	_ = os.Remove(globalconfig.GetMutagenPath())
+	err := fileutil.RemoveFilesMatchingGlob(filepath.Join(globalconfig.GetDDEVBinDir(), "mutagen*"))
+	if err != nil {
+		return fmt.Errorf("Unable to remove mutagen files: %v", err)
+	}
 
-	_ = os.MkdirAll(globalMutagenDir, 0777)
-	err := util.DownloadFile(destFile, mutagenURL, "true" != os.Getenv("DDEV_NONINTERACTIVE"))
+	err = os.MkdirAll(globalMutagenDir, 0777)
+	if err != nil {
+		return errors.Errorf("Unable to create directory %s: %v", globalMutagenDir, err)
+	}
+	err = util.DownloadFile(destFile, mutagenURL, "true" != os.Getenv("DDEV_NONINTERACTIVE"), shasumFileURL)
 	if err != nil {
+		_ = fileutil.RemoveFilesMatchingGlob(filepath.Join(globalconfig.GetDDEVBinDir(), "mutagen*"))
+		_ = os.Remove(destFile)
 		return err
 	}
 	output.UserOut.Printf("Download complete.")
diff --git a/pkg/ddevapp/templates.go b/pkg/ddevapp/templates.go
index b3fcb75d6..a568d6096 100644
--- a/pkg/ddevapp/templates.go
+++ b/pkg/ddevapp/templates.go
@@ -6,6 +6,8 @@ const ConfigInstructions = `
 
 # name: <projectname> # Name of the project, automatically provides
 #   http://projectname.ddev.site and https://projectname.ddev.site
+# If the name is omitted, the project will take the name of the enclosing directory,
+# which is useful if you want to have a copy of the project side by side with this one.
 
 # type: <projecttype>  # backdrop, cakephp, craftcms, drupal, drupal6, drupal7, drupal8, drupal9, drupal10, drupal11, generic, laravel, magento, magento2, php, shopware6, silverstripe, symfony, typo3, wordpress
 # See https://ddev.readthedocs.io/en/stable/users/quickstart/ for more
diff --git a/pkg/dockerutil/dockerutils.go b/pkg/dockerutil/dockerutils.go
index 65e47acef..a972736ca 100644
--- a/pkg/dockerutil/dockerutils.go
+++ b/pkg/dockerutil/dockerutils.go
@@ -1585,7 +1585,7 @@ func DownloadDockerCompose() error {
 	globalBinDir := globalconfig.GetDDEVBinDir()
 	destFile, _ := globalconfig.GetDockerComposePath()
 
-	composeURL, err := dockerComposeDownloadLink()
+	composeURL, shasumURL, err := dockerComposeDownloadLink()
 	if err != nil {
 		return err
 	}
@@ -1594,8 +1594,9 @@ func DownloadDockerCompose() error {
 	_ = os.Remove(destFile)
 
 	_ = os.MkdirAll(globalBinDir, 0777)
-	err = util.DownloadFile(destFile, composeURL, "true" != os.Getenv("DDEV_NONINTERACTIVE"))
+	err = util.DownloadFile(destFile, composeURL, "true" != os.Getenv("DDEV_NONINTERACTIVE"), shasumURL)
 	if err != nil {
+		_ = os.Remove(destFile)
 		return err
 	}
 	output.UserOut.Printf("Download complete.")
@@ -1611,26 +1612,8 @@ func DownloadDockerCompose() error {
 	return nil
 }
 
-func dockerComposeDownloadLink() (string, error) {
-	v := globalconfig.GetRequiredDockerComposeVersion()
-	if len(v) < 3 {
-		return "", fmt.Errorf("required docker-compose version is invalid: %v", v)
-	}
-	baseVersion := v[1:2]
-
-	switch baseVersion {
-	case "2":
-		return dockerComposeDownloadLinkV2()
-	}
-	return "", fmt.Errorf("invalid docker-compose base version %s", v)
-}
-
-// dockerComposeDownloadLinkV2 downlods compose v1 downloads like
-//   https://github.com/docker/compose/releases/download/v2.2.1/docker-compose-darwin-aarch64
-//   https://github.com/docker/compose/releases/download/v2.2.1/docker-compose-darwin-x86_64
-//   https://github.com/docker/compose/releases/download/v2.2.1/docker-compose-windows-x86_64.exe
-
-func dockerComposeDownloadLinkV2() (string, error) {
+// dockerComposeDownloadLink returns the URL and SHASUM-file link for docker-compose
+func dockerComposeDownloadLink() (composeURL string, shasumURL string, err error) {
 	arch := runtime.GOARCH
 
 	switch arch {
@@ -1639,14 +1622,16 @@ func dockerComposeDownloadLinkV2() (string, error) {
 	case "amd64":
 		arch = "x86_64"
 	default:
-		return "", fmt.Errorf("only ARM64 and AMD64 architectures are supported for docker-compose v2, not %s", arch)
+		return "", "", fmt.Errorf("only ARM64 and AMD64 architectures are supported for docker-compose, not %s", arch)
 	}
 	flavor := runtime.GOOS + "-" + arch
-	ComposeURL := fmt.Sprintf("https://github.com/docker/compose/releases/download/%s/docker-compose-%s", globalconfig.GetRequiredDockerComposeVersion(), flavor)
+	composerURL := fmt.Sprintf("https://github.com/docker/compose/releases/download/%s/docker-compose-%s", globalconfig.GetRequiredDockerComposeVersion(), flavor)
 	if runtime.GOOS == "windows" {
-		ComposeURL = ComposeURL + ".exe"
+		composerURL = composerURL + ".exe"
 	}
-	return ComposeURL, nil
+	shasumURL = fmt.Sprintf("https://github.com/docker/compose/releases/download/%s/checksums.txt", globalconfig.GetRequiredDockerComposeVersion())
+
+	return composerURL, shasumURL, nil
 }
 
 // IsDockerDesktop detects if running on Docker Desktop
diff --git a/pkg/fileutil/files.go b/pkg/fileutil/files.go
index 79cab3141..a6a4ae0b6 100644
--- a/pkg/fileutil/files.go
+++ b/pkg/fileutil/files.go
@@ -5,6 +5,7 @@ import (
 	"crypto/rand"
 	"encoding/hex"
 	"fmt"
+	"github.com/pkg/errors"
 	"io"
 	"io/fs"
 	"os"
@@ -408,6 +409,28 @@ func RemoveContents(dir string) error {
 	return nil
 }
 
+// RemoveFilesMatchingGlob removes all files matching a given glob pattern.
+// It does nothing if the directory does not exist.
+func RemoveFilesMatchingGlob(pattern string) error {
+	matches, err := filepath.Glob(pattern)
+	if err != nil {
+		return errors.Errorf("Error finding files matching %s: %v", pattern, err)
+	}
+
+	// If no matches, just return (e.g., directory might not exist)
+	if len(matches) == 0 {
+		return nil
+	}
+
+	for _, match := range matches {
+		if err := os.Remove(match); err != nil {
+			return errors.Errorf("Unable to remove file %s: %v", match, err)
+		}
+	}
+
+	return nil
+}
+
 // TemplateStringToFile takes a template string, runs templ.Execute on it, and writes it out to file
 func TemplateStringToFile(content string, vars map[string]interface{}, targetFilePath string) error {
 
diff --git a/pkg/util/network.go b/pkg/util/network.go
index 88dc9455f..76a9f1ac6 100644
--- a/pkg/util/network.go
+++ b/pkg/util/network.go
@@ -1,6 +1,8 @@
 package util
 
 import (
+	"bytes"
+	"crypto/sha256"
 	"errors"
 	"fmt"
 	"golang.org/x/term"
@@ -16,16 +18,33 @@ import (
 )
 
 // DownloadFile retrieves a file.
-func DownloadFile(destPath string, url string, progressBar bool) (err error) {
+func DownloadFile(destPath string, url string, progressBar bool, shaSumURL string) (err error) {
 	if output.JSONOutput || !term.IsTerminal(int(os.Stdin.Fd())) {
 		progressBar = false
 	}
+
+	// If shaSumURL is provided, download and read the expected SHASUM
+	var expectedSHA string
+	if shaSumURL != "" {
+		resp, err := http.Get(shaSumURL)
+		if err != nil {
+			return fmt.Errorf("failed to download shaSum URL %s: %v", shaSumURL, err)
+		}
+		defer CheckClose(resp.Body)
+
+		shaBytes, err := io.ReadAll(resp.Body)
+		if err != nil {
+			return fmt.Errorf("failed to read shaSum file: %v", err)
+		}
+		expectedSHA = string(bytes.TrimSpace(shaBytes))
+	}
+
 	// Create the file
-	out, err := os.Create(destPath)
+	outFile, err := os.Create(destPath)
 	if err != nil {
 		return err
 	}
-	defer CheckClose(out)
+	defer CheckClose(outFile)
 
 	// Get the data
 	resp, err := http.Get(url)
@@ -37,25 +56,56 @@ func DownloadFile(destPath string, url string, progressBar bool) (err error) {
 	if resp.StatusCode != http.StatusOK {
 		return fmt.Errorf("download link %s returned wrong status code: got %v want %v", url, resp.StatusCode, http.StatusOK)
 	}
+
 	reader := resp.Body
 	if progressBar {
-
 		bar := pb.New(int(resp.ContentLength)).SetUnits(pb.U_BYTES).Prefix(filepath.Base(destPath))
 		bar.Start()
-
-		// create proxy reader
 		reader = bar.NewProxyReader(resp.Body)
-		// Writer the body to file
-		_, err = io.Copy(out, reader)
-		bar.Finish()
-	} else {
-		_, err = io.Copy(out, reader)
+		defer bar.Finish()
 	}
 
-	if err != nil {
+	hasher := sha256.New()
+	writer := io.MultiWriter(outFile, hasher)
+	if _, err = io.Copy(writer, reader); err != nil {
 		return err
 	}
 
+	if expectedSHA != "" {
+		baseName := filepath.Base(url)
+		var matchedSHA string
+
+		lines := bytes.Split([]byte(expectedSHA), []byte{'\n'})
+		for _, line := range lines {
+			fields := bytes.Fields(line)
+			if len(fields) != 2 {
+				continue
+			}
+			filename := bytes.TrimPrefix(fields[1], []byte("*"))
+			if bytes.Equal(filename, []byte(baseName)) {
+				matchedSHA = string(fields[0])
+				break
+			}
+		}
+
+		if matchedSHA == "" {
+			_ = outFile.Close()
+			_ = os.Remove(destPath)
+			return fmt.Errorf("no matching SHA256 found for %s in shaSum file", baseName)
+		}
+
+		actualSHA := fmt.Sprintf("%x", hasher.Sum(nil))
+		if actualSHA != matchedSHA {
+			_ = outFile.Close()
+			fileRemoveErr := os.Remove(destPath)
+			msg := fmt.Sprintf("SHA256 mismatch: expected %s, got %s", matchedSHA, actualSHA)
+			if fileRemoveErr != nil {
+				msg += fmt.Sprintf("\nAlso failed to remove file %s: %v", destPath, fileRemoveErr)
+			}
+			return errors.New(msg)
+		}
+	}
+
 	return nil
 }
 


Test patch:
diff --git a/cmd/ddev/cmd/config_test.go b/cmd/ddev/cmd/config_test.go
index 942ce5ee4..d0773b1e1 100644
--- a/cmd/ddev/cmd/config_test.go
+++ b/cmd/ddev/cmd/config_test.go
@@ -888,3 +888,64 @@ func TestDrupalAppTypeUsage(t *testing.T) {
 	require.NoError(t, err)
 	require.Equal(t, nodeps.AppTypeDrupalLatestStable, app.Type)
 }
+
+// TestHasConfigNameOverride tests that `ddev config` has name override from `.ddev/config.*.yaml` files
+// and new name is not written to .ddev/config.yaml
+func TestHasConfigNameOverride(t *testing.T) {
+	assert := asrt.New(t)
+
+	projectName := strings.ToLower(t.Name())
+	origDir, _ := os.Getwd()
+	_, _ = exec.RunHostCommand(DdevBin, "stop", "--unlist", projectName)
+
+	// Create a temporary directory and switch to it.
+	tmpDir := testcommon.CreateTmpDir(t.Name())
+	_ = os.Chdir(tmpDir)
+
+	var err error
+
+	t.Cleanup(func() {
+		err = os.Chdir(origDir)
+		assert.NoError(err)
+		out, err := exec.RunHostCommand(DdevBin, "delete", "-Oy", projectName)
+		assert.NoError(err, "output=%s", out)
+		_ = os.RemoveAll(tmpDir)
+	})
+
+	_ = os.Chdir(tmpDir)
+
+	// Create a dummy config file to make it look like it's an existing project
+	// Otherwise DDEV will not read the override
+	err = os.MkdirAll(filepath.Join(tmpDir, ".ddev"), 0777)
+	require.NoError(t, err)
+	configFile := filepath.Join(tmpDir, ".ddev/config.yaml")
+	configFileLocal := filepath.Join(tmpDir, ".ddev/config.local.yaml")
+	_, err = os.Create(configFile)
+	require.NoError(t, err)
+
+	// Create an override for the project name
+	projectNameOverride := projectName + "-override"
+	err = os.WriteFile(configFileLocal, []byte(`name: `+projectNameOverride), 0644)
+	require.NoError(t, err)
+
+	out, err := exec.RunHostCommand(DdevBin, "config", "--auto")
+	require.NoError(t, err, "error running ddev config --auto: '%s'", out)
+
+	configContents, err := os.ReadFile(configFile)
+	require.NoError(t, err, "Unable to read '%s'", configFile)
+	configLocalContents, err := os.ReadFile(configFileLocal)
+	require.NoError(t, err, "Unable to read '%s'", configFileLocal)
+
+	app := &ddevapp.DdevApp{}
+	err = yaml.Unmarshal(configContents, app)
+	require.NoError(t, err, "Could not unmarshal '%s'", configFile)
+
+	// app.Name inside .ddev/config.yaml should be empty
+	require.Equal(t, "", app.Name)
+
+	err = yaml.Unmarshal(configLocalContents, app)
+	require.NoError(t, err, "Could not unmarshal '%s'", configFileLocal)
+
+	// app.Name inside .ddev/config.local.yaml should have the expected value
+	require.Equal(t, projectNameOverride, app.Name)
+}
diff --git a/docs/content/developers/buildkite-testmachine-setup.md b/docs/content/developers/buildkite-testmachine-setup.md
index 819aa8a6a..a1de54a58 100644
--- a/docs/content/developers/buildkite-testmachine-setup.md
+++ b/docs/content/developers/buildkite-testmachine-setup.md
@@ -11,13 +11,13 @@ We are using [Buildkite](https://buildkite.com/ddev) for Windows and macOS testi
 1. Create the user “testbot” on the machine. Use the password for `ddevtestbot@gmail.com`, available in 1Password.
 2. In admin PowerShell, `wsl --install`.
 3. (WSL2/Docker Desktop and traditional Windows only): Install [Docker Desktop for Windows](https://docs.docker.com/desktop/release-notes/) (from the release notes page, which is best maintained)
-4. In admin PowerShell, `Set-ExecutionPolicy -Scope "CurrentUser" -ExecutionPolicy "RemoteSigned"`.
+4. In admin PowerShell, `Set-ExecutionPolicy -Scope "CurrentUser" -ExecutionPolicy "Unrestricted"`.
 5. In admin PowerShell, download and run [windows_buildkite_start.ps1](scripts/windows_buildkite_start.ps1) with `curl <url> -O windows_buildkite_start.ps1`.
 6. Install items as needed; `git`, `jq`, `mysql-cli`, `golang`, `make` are only required for a traditional Windows test machine. `choco install -y git jq  mysql-cli golang make mkcert netcat zip`.
 7. After restart, in **administrative** Git Bash window, `Rename-Computer <testbot-win10(home|pro)-<description>-1` and then `export BUILDKITE_AGENT_TOKEN=<token>`.
 8. (Traditional Windows test runner only): Download and run [windows_buildkite_setup.sh](scripts/windows_buildkite_setup.sh).
 9. (Traditional Windows test runner only): Download and run [windows_postinstall.sh](scripts/windows_postinstall.sh).
-10. Launch Docker. It may require you to take further actions.
+10. (Traditional Windows or Docker Desktop WSL2 Only) Launch Docker. It may require you to take further actions.
     * Check "Start Docker Desktop when you sign in"
     * Check "Add the *.docker.internal names to the host's /etc/hosts file"
     * Uncheck "SBOM Indexing"
diff --git a/docs/tests/drupal.bats b/docs/tests/drupal.bats
index 09ec75fc8..31ae97a5c 100644
--- a/docs/tests/drupal.bats
+++ b/docs/tests/drupal.bats
@@ -116,6 +116,12 @@ teardown() {
   # ddev composer create drupal/cms
   run ddev composer create drupal/cms
   assert_success
+  # Run Drush site install to set up the site
+  run ddev drush si --account-name=admin --account-pass=admin -y
+  assert_success
+  # Apply one of the additional recipes Drupal CMS ships with
+  run ddev drush recipe ../recipes/drupal_cms_blog
+  assert_success
   # ddev launch
   run bash -c "DDEV_DEBUG=true ddev launch"
   assert_output "FULLURL https://${PROJNAME}.ddev.site"
@@ -123,7 +129,11 @@ teardown() {
   # validate running project
   run curl -sfI https://${PROJNAME}.ddev.site
   assert_success
-  assert_output --partial "location: /core/install.php"
-  assert_output --partial "HTTP/2 302"
+  assert_output --partial "HTTP/2 200"
+  assert_output --partial "expires: Sun, 19 Nov 1978 05:00:00 GMT"
   assert_output --partial "x-generator: Drupal 11 (https://www.drupal.org)"
+  run curl -sf https://${PROJNAME}.ddev.site
+  assert_success
+  assert_output --partial "<title>Home | Drush Site-Install</title>"
+  assert_output --partial "<span>A celebration of Drupal and open-source contributions</span>"
 }
diff --git a/pkg/archive/archive_test.go b/pkg/archive/archive_test.go
index cf58653dc..4d15ac524 100644
--- a/pkg/archive/archive_test.go
+++ b/pkg/archive/archive_test.go
@@ -159,14 +159,13 @@ func TestExtractTarballWithCleanup(t *testing.T) {
 
 // TestDownloadAndExtractTarball tests DownloadAndExtractTarball
 func TestDownloadAndExtractTarball(t *testing.T) {
-	assert := asrt.New(t)
-
 	testTarball := "https://github.com/ddev/ddev-drupal-solr/archive/refs/tags/v1.2.3.tar.gz"
 
 	dir, cleanup, err := archive.DownloadAndExtractTarball(testTarball, true)
+	defer cleanup()
 	require.NoError(t, err)
-	assert.DirExists(dir)
-	assert.FileExists(path.Join(dir, "install.yaml"))
+	require.DirExists(t, dir)
+	require.FileExists(t, path.Join(dir, "install.yaml"))
 	cleanup()
-	assert.NoDirExists(dir)
+	require.NoDirExists(t, dir)
 }
diff --git a/pkg/dockerutil/docker_compose_version_test.go b/pkg/dockerutil/docker_compose_version_test.go
index 54adc61e1..50d47c0a5 100644
--- a/pkg/dockerutil/docker_compose_version_test.go
+++ b/pkg/dockerutil/docker_compose_version_test.go
@@ -13,7 +13,7 @@ import (
 
 var DdevBin = "ddev"
 
-// TestDockerComposeDownload
+// TestDockerComposeDownload verifies that we can download a particular docker-compose version
 func TestDockerComposeDownload(t *testing.T) {
 	assert := asrt.New(t)
 	var err error
@@ -50,7 +50,7 @@ func TestDockerComposeDownload(t *testing.T) {
 	assert.NoError(err)
 	assert.False(downloaded)
 
-	for _, v := range []string{"v2.18.0"} {
+	for _, v := range []string{"v2.32.4"} {
 		globalconfig.DockerComposeVersion = ""
 		globalconfig.DdevGlobalConfig.RequiredDockerComposeVersion = v
 		downloaded, err = dockerutil.DownloadDockerComposeIfNeeded()
diff --git a/pkg/fileutil/files_test.go b/pkg/fileutil/files_test.go
index 515c7bc7b..a989f447f 100644
--- a/pkg/fileutil/files_test.go
+++ b/pkg/fileutil/files_test.go
@@ -245,3 +245,44 @@ func TestIsSameFile(t *testing.T) {
 	assert.NoError(err)
 	assert.False(isSame)
 }
+
+// TestRemoveFilesMatchingGlob tests that RemoveFilesMatchingGlob works
+func TestRemoveFilesMatchingGlob(t *testing.T) {
+	tmpDir := t.TempDir()
+
+	// Create a few test files
+	files := []string{"match1.log", "match2.log", "keep.txt"}
+	for _, name := range files {
+		err := os.WriteFile(filepath.Join(tmpDir, name), []byte("test content"), 0644)
+		if err != nil {
+			t.Fatalf("failed to create test file %s: %v", name, err)
+		}
+	}
+
+	// Call RemoveFilesMatchingGlob to remove *.log files
+	err := fileutil.RemoveFilesMatchingGlob(filepath.Join(tmpDir, "*.log"))
+	if err != nil {
+		t.Fatalf("RemoveFilesMatchingGlob returned error: %v", err)
+	}
+
+	// Assert that .log files are gone and .txt remains
+	for _, name := range files {
+		path := filepath.Join(tmpDir, name)
+		_, err := os.Stat(path)
+		if filepath.Ext(name) == ".log" {
+			if !os.IsNotExist(err) {
+				t.Errorf("expected file %s to be removed, but it exists", path)
+			}
+		} else {
+			if err != nil {
+				t.Errorf("expected file %s to exist, but got error: %v", path, err)
+			}
+		}
+	}
+
+	// It should not error when nothing matches
+	err = fileutil.RemoveFilesMatchingGlob(filepath.Join(tmpDir, "*.nomatch"))
+	if err != nil {
+		t.Errorf("expected no error when no files match, got: %v", err)
+	}
+}
diff --git a/pkg/testcommon/testcommon.go b/pkg/testcommon/testcommon.go
index fca9bc3cf..bb8a6fae0 100644
--- a/pkg/testcommon/testcommon.go
+++ b/pkg/testcommon/testcommon.go
@@ -408,7 +408,7 @@ func GetCachedArchive(_, _, internalExtractionPath, sourceURL string) (string, s
 	if aErr != nil {
 		output.UserOut.Printf("Downloading %s", sourceURL)
 
-		err := util.DownloadFile(archiveFullPath, sourceURL, false)
+		err := util.DownloadFile(archiveFullPath, sourceURL, false, "")
 		if err != nil {
 			_ = os.RemoveAll(archiveFullPath)
 			return extractPath, archiveFullPath, fmt.Errorf("failed to download url=%s into %s, err=%v", sourceURL, archiveFullPath, err)
diff --git a/pkg/util/network_test.go b/pkg/util/network_test.go
new file mode 100644
index 000000000..a90084fe1
--- /dev/null
+++ b/pkg/util/network_test.go
@@ -0,0 +1,77 @@
+package util
+
+import (
+	"crypto/sha256"
+	"fmt"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+// TestDownloadFile tests downloading a file with no sha, good sha, bad sha
+func TestDownloadFile(t *testing.T) {
+	testData := "hello world\n"
+	sum := fmt.Sprintf("%x", sha256.Sum256([]byte(testData)))
+	fileName := "testfile.txt"
+
+	// HTTP test server
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		switch r.URL.Path {
+		case "/" + fileName:
+			_, _ = io.WriteString(w, testData)
+		case "/sha256sums.txt":
+			fmt.Fprintf(w, "%s *%s\n", sum, fileName)
+		case "/badsha256sums.txt":
+			fmt.Fprintf(w, "%s *%s\n", "deadbeef", fileName)
+		default:
+			http.NotFound(w, r)
+		}
+	}))
+	defer ts.Close()
+
+	tmpDir := t.TempDir()
+
+	t.Run("no shasum", func(t *testing.T) {
+		dest := filepath.Join(tmpDir, "plain.txt")
+		err := DownloadFile(dest, ts.URL+"/"+fileName, false, "")
+		if err != nil {
+			t.Fatalf("unexpected error: %v", err)
+		}
+		content, err := os.ReadFile(dest)
+		if err != nil {
+			t.Fatalf("failed to read downloaded file: %v", err)
+		}
+		if string(content) != testData {
+			t.Fatalf("file content mismatch: got %q, want %q", content, testData)
+		}
+	})
+
+	t.Run("correct shasum", func(t *testing.T) {
+		dest := filepath.Join(tmpDir, "verified.txt")
+		err := DownloadFile(dest, ts.URL+"/"+fileName, false, ts.URL+"/sha256sums.txt")
+		if err != nil {
+			t.Fatalf("unexpected error: %v", err)
+		}
+		content, err := os.ReadFile(dest)
+		if err != nil {
+			t.Fatalf("failed to read downloaded file: %v", err)
+		}
+		if string(content) != testData {
+			t.Fatalf("file content mismatch: got %q, want %q", content, testData)
+		}
+	})
+
+	t.Run("incorrect shasum", func(t *testing.T) {
+		dest := filepath.Join(tmpDir, "bad.txt")
+		err := DownloadFile(dest, ts.URL+"/"+fileName, false, ts.URL+"/badsha256sums.txt")
+		if err == nil || err.Error() == "" {
+			t.Fatal("expected SHA256 mismatch error, got nil")
+		}
+		if _, statErr := os.Stat(dest); !os.IsNotExist(statErr) {
+			t.Fatalf("expected file %s to be deleted after sha mismatch, but it exists", dest)
+		}
+	})
+}