Use external temporary storage for release builds

This commit is contained in:
2026-10-06 01:00:11 +00:00
parent 4dee2841e0
commit 8b417760c6
8 changed files with 109 additions and 11 deletions

View File

@@ -520,3 +520,32 @@ func (w *sourceChangeWriter) Write(p []byte) (int, error) {
} }
return len(p), nil return len(p), nil
} }
func TestCandidateBuildDoesNotRequireWritableRepositoryParent(t *testing.T) {
if runtime.GOOS == "windows" || os.Getuid() == 0 {
t.Skip("requires ordinary Unix permission checks")
}
repository := newBuildConsumer(t, `package main
import("fmt";"gitea.maximumdirect.net/eric/ReleaseKit/buildinfo")
func main(){fmt.Println("fixture",buildinfo.Version())}
`)
writeBuildFixture(t, repository, "docs/releases/v1.2.3.md", "# Fixture v1.2.3\n")
buildGit(t, repository, "add", "--all")
buildGit(t, repository, "commit", "-m", "note")
parent := t.TempDir()
moved := filepath.Join(parent, "repo")
if err := os.Rename(repository, moved); err != nil {
t.Fatal(err)
}
if err := os.Chmod(parent, 0555); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.Chmod(parent, 0700) })
_, err := releasekit.Check(context.Background(), releasekit.ReleaseRequest{RepositoryDir: moved, Product: "Fixture", Version: "v1.2.3", Validate: func(context.Context, releasekit.Candidate) error { return nil }, Build: &releasekit.BuildSpec{MainPackage: "./cmd/app", BinaryName: "fixture", Targets: []releasekit.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}}})
if err != nil {
t.Fatal(err)
}
if status := buildGit(t, moved, "status", "--porcelain"); status != "" {
t.Fatalf("candidate dirtied: %s", status)
}
}

View File

@@ -14,6 +14,7 @@ import (
"strings" "strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/process" "gitea.maximumdirect.net/eric/ReleaseKit/internal/process"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/tempdir"
) )
const Usage = "usage: releasekit version\n releasekit check [--repo DIR] [--config FILE] VERSION\n releasekit publish [--repo DIR] [--config FILE] VERSION\n releasekit build [--repo DIR] [--config FILE] [--output DIR] VERSION\n releasekit deliver [--repo DIR] [--config FILE] [--assets DIR] VERSION" const Usage = "usage: releasekit version\n releasekit check [--repo DIR] [--config FILE] VERSION\n releasekit publish [--repo DIR] [--config FILE] VERSION\n releasekit build [--repo DIR] [--config FILE] [--output DIR] VERSION\n releasekit deliver [--repo DIR] [--config FILE] [--assets DIR] VERSION"
@@ -202,3 +203,8 @@ func RunChecks(ctx context.Context, repoDir string, checks []CheckCommand, owned
} }
return nil return nil
} }
// TemporaryBuildDir allocates command-owned storage outside its source checkout.
func TemporaryBuildDir(repository string) (string, error) {
return tempdir.Outside(repository, "releasekit-delivery-")
}

View File

@@ -197,7 +197,7 @@ func runDelivery(ctx context.Context, invocation cli.Invocation, stdout, stderr
fmt.Fprintf(stderr, "resolve repository directory: %v\n", resolveErr) fmt.Fprintf(stderr, "resolve repository directory: %v\n", resolveErr)
return 1 return 1
} }
temporaryDir, err = os.MkdirTemp(filepath.Dir(repositoryDir), ".releasekit-delivery-") temporaryDir, err = cli.TemporaryBuildDir(repositoryDir)
if err != nil { if err != nil {
fmt.Fprintf(stderr, "create temporary build directory: %v\n", err) fmt.Fprintf(stderr, "create temporary build directory: %v\n", err)
return 1 return 1

View File

@@ -762,3 +762,33 @@ func main(){fmt.Println("fixture",buildinfo.Version())}
t.Fatalf("stale receipt remains: %v", err) t.Fatalf("stale receipt remains: %v", err)
} }
} }
func TestTemporaryDeliveryBuildDoesNotRequireWritableRepositoryParent(t *testing.T) {
if runtime.GOOS == "windows" || os.Getuid() == 0 {
t.Skip("requires ordinary Unix permission checks")
}
repository := newDeliveryBuildRepository(t)
state := &deliveryServerState{}
server := newDeliveryServer(t, state)
defer server.Close()
writeDeliveryProfile(t, repository, cli.Settings{Product: "Example Product", Build: &cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}, Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"}})
state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD")
cliGitRun(t, repository, "tag", "v1.2.3")
parent := t.TempDir()
moved := filepath.Join(parent, "repo")
if err := os.Rename(repository, moved); err != nil {
t.Fatal(err)
}
if err := os.Chmod(parent, 0555); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.Chmod(parent, 0700) })
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
var stdout, stderr bytes.Buffer
if status := run(context.Background(), []string{"deliver", "--repo", moved, "v1.2.3"}, &stdout, &stderr); status != 0 {
t.Fatalf("status=%d %s", status, stderr.String())
}
if status := cliGitOutput(t, moved, "status", "--porcelain"); status != "" {
t.Fatalf("source dirtied: %s", status)
}
}

View File

@@ -39,6 +39,7 @@ these implemented packages:
| `internal/delivery` | Tagged-checkout verification, artifact inventory/hash validation, manifest assembly, and coordination of hosted delivery results. | | `internal/delivery` | Tagged-checkout verification, artifact inventory/hash validation, manifest assembly, and coordination of hosted delivery results. |
| `internal/git` | Git command boundary, push-destination resolution, exact ref inspection, and tag operations. | | `internal/git` | Git command boundary, push-destination resolution, exact ref inspection, and tag operations. |
| `internal/gitea` | Gitea API transport, hosted release pages, asset inspection, manifests, and uploads. | | `internal/gitea` | Gitea API transport, hosted release pages, asset inspection, manifests, and uploads. |
| `internal/tempdir` | Disposable system temporary storage outside source worktrees. |
| `internal/process` | Cancellable child-process execution with per-command environment overlays. | | `internal/process` | Cancellable child-process execution with per-command environment overlays. |
| `internal/publish` | Publication guards, one validation run, annotated tag creation, push, and recovery state. | | `internal/publish` | Publication guards, one validation run, annotated tag creation, push, and recovery state. |
| `internal/runtimeinfo` | Private version lookup used by the runtime facade. | | `internal/runtimeinfo` | Private version lookup used by the runtime facade. |

View File

@@ -230,6 +230,8 @@ offline suite, and compiled-export/import checks pass.
### R6 — Medium: Builds require a writable parent of the repository ### R6 — Medium: Builds require a writable parent of the repository
Status: Resolved.
Category: Inadvertently strengthened filesystem prerequisite. Category: Inadvertently strengthened filesystem prerequisite.
**Evidence:** [Candidate builds](../../internal/build/candidate.go), around line 21, **Evidence:** [Candidate builds](../../internal/build/candidate.go), around line 21,
@@ -250,6 +252,11 @@ adding broad output-directory restrictions.
read-only repository parent and a usable system temporary directory. Generated read-only repository parent and a usable system temporary directory. Generated
candidate output still leaves the worktree clean. candidate output still leaves the worktree clean.
**Resolution:** Candidate and CLI delivery builds use shared system temporary
storage allocated by `internal/tempdir`, with worktree containment checks and
caller-owned cleanup. Regressions execute both operations with read-only
repository parents and confirm clean worktrees. Affected suites pass.
### R7 — Medium: Attachment origin restrictions exceed credential isolation ### R7 — Medium: Attachment origin restrictions exceed credential isolation
Category: Inadvertently strengthened prohibition. Category: Inadvertently strengthened prohibition.

View File

@@ -5,10 +5,9 @@ import (
"fmt" "fmt"
"io" "io"
"os" "os"
"path/filepath"
"strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate" "gitea.maximumdirect.net/eric/ReleaseKit/internal/candidate"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/tempdir"
) )
func CandidateCallback(spec *Spec, output io.Writer) func(context.Context, candidate.Candidate) error { func CandidateCallback(spec *Spec, output io.Writer) func(context.Context, candidate.Candidate) error {
@@ -18,18 +17,11 @@ func CandidateCallback(spec *Spec, output io.Writer) func(context.Context, candi
selected := *spec selected := *spec
selected.Targets = append([]Target(nil), spec.Targets...) selected.Targets = append([]Target(nil), spec.Targets...)
return func(ctx context.Context, captured candidate.Candidate) error { return func(ctx context.Context, captured candidate.Candidate) error {
temporaryDir, err := os.MkdirTemp(filepath.Dir(captured.RepositoryDir), ".releasekit-candidate-build-") temporaryDir, err := tempdir.Outside(captured.RepositoryDir, "releasekit-candidate-build-")
if err != nil { if err != nil {
return fmt.Errorf("create temporary candidate build output: %w", err) return fmt.Errorf("create temporary candidate build output: %w", err)
} }
defer os.RemoveAll(temporaryDir) defer os.RemoveAll(temporaryDir)
relative, err := filepath.Rel(captured.RepositoryDir, temporaryDir)
if err != nil {
return fmt.Errorf("verify temporary candidate build output: %w", err)
}
if relative == "." || relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return fmt.Errorf("temporary candidate build output %q is inside the repository", temporaryDir)
}
_, err = Build(ctx, Request{ _, err = Build(ctx, Request{
RepositoryDir: captured.RepositoryDir, RepositoryDir: captured.RepositoryDir,
Version: captured.Version, Version: captured.Version,

View File

@@ -0,0 +1,33 @@
// Package tempdir allocates disposable storage outside a source worktree.
package tempdir
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// Outside uses the system temporary directory and leaves cleanup to its caller.
func Outside(repositoryDir, pattern string) (string, error) {
directory, err := os.MkdirTemp("", pattern)
if err != nil {
return "", err
}
repository, err := filepath.Abs(repositoryDir)
if err == nil && !strings.EqualFold(filepath.VolumeName(repository), filepath.VolumeName(directory)) {
return directory, nil
}
if err == nil {
var relative string
relative, err = filepath.Rel(repository, directory)
if err == nil && (relative == "." || relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))) {
err = fmt.Errorf("temporary storage %q is inside repository %q; select an external temporary directory", directory, repository)
}
}
if err != nil {
os.RemoveAll(directory)
return "", err
}
return directory, nil
}