Use external temporary storage for release builds
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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-")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
33
internal/tempdir/tempdir.go
Normal file
33
internal/tempdir/tempdir.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user