Merge pull request 'Pack archive artifacts in mesh-build-go, a module's directory or a pinned file, and refuse any path that leads out of the module through a link (issue 1589, ADR 0478 §1)' (#408) from fix/1589-archive-artifacts into main

This commit was merged in pull request #408.
This commit is contained in:
2026-10-21 10:34:00 +00:00
13 changed files with 741 additions and 10 deletions
+7 -1
View File
@@ -96,4 +96,10 @@ module's directory, by the installed go (`GOTOOLCHAIN=local`) from the module ca
(`GOPROXY=off`). The binary is named as the build agent names it. The output directory is packed with mesh-sdk's `Pack`,
as the build agent packs; two build nodes on the same toolchain give the same digest for the same tree (ADR 0477 §6). The step ends with the artifact, the binary, the digest, the size and
the path of the archive, which stays in the workspace's `artifacts` directory: publishing it is the builder's, in a later
release. An artifact that is not a Go bundle fails the step naming it.
release. An artifact that is neither a Go bundle nor an archive fails the step naming it.
The same tool packs an archive artifact of any module's tree, whatever its language (ADR 0478 §1, issue 1589): the
directory named by `from`, or a file pinned by `url` and `sha256`, packed with the same `Pack` as the build agent's `pack`
(`testdata/captured/README` says how its digest was captured). A pinned file is fetched, checked against its sha256 (a
mismatch fails the step naming both digests) and laid out as the artifact says (`file`, or unpacked from `within`; gzip and
bzip2, not yet xz); it runs no command and needs no Go.
@@ -0,0 +1,354 @@
package main
// The archive artifact (novox/hq ADR 0478 §1, issue 1589): a directory of a module's tree, or a file pinned by url and
// sha256, packed as the build agent packs it (mesh-controller internal/builder: the archive case of `one`, `pack`, and
// upstream_file.go), with mesh-sdk's Pack. Any module's archive is packed here, whatever language the module is in.
import (
"archive/tar"
"bufio"
"bytes"
"compress/bzip2"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// maxUpstream is the largest pinned file fetched and the largest it may unpack to, as the build agent's and the
// node-engine's (a machine unpacks no archive larger).
const maxUpstream = 512 << 20
// maxEntries is the most entries a pinned archive may hold.
const maxEntries = 100_000
func (m *Module) httpClient() *http.Client {
if m.HTTP != nil {
return m.HTTP
}
return &http.Client{Timeout: 15 * time.Minute}
}
// buildArchive packs one archive artifact of the module in dir: a pinned file when it names a url, else the directory
// `from` of the module's tree.
func (m *Module) buildArchive(ctx context.Context, workspace, module, dir string, art catalogue.Artifact) (any, error) {
if art.URL != "" {
return m.pinnedArchive(ctx, workspace, module, art)
}
from := strings.Trim(path.Clean("/"+filepath.ToSlash(art.From)), "/")
if strings.TrimSpace(art.From) == "" {
return nil, fmt.Errorf("the artifact %s says nothing it is built from", art.Name)
}
root := filepath.Join(dir, filepath.FromSlash(from))
if rel, err := filepath.Rel(dir, root); err != nil || strings.HasPrefix(rel, "..") {
return nil, fmt.Errorf("the archive %s is taken from %q, which lies outside the module", art.Name, art.From)
}
if strings.Contains(filepath.ToSlash(art.From), "..") {
return nil, fmt.Errorf("the archive %s is taken from %q, which lies outside the module", art.Name, art.From)
}
// A link in the fetched tree must not lead out of the module: compare where both really are.
realDir, err := filepath.EvalSymlinks(dir)
if err != nil {
return nil, err
}
realTree, err := filepath.EvalSymlinks(filepath.Join(m.Work, workspace, "tree"))
if err != nil {
return nil, err
}
if !within(realTree, realDir) {
return nil, fmt.Errorf("the module directory of %s resolves to %s, which lies outside the workspace's tree %s", art.Name, realDir, realTree)
}
realRoot, err := filepath.EvalSymlinks(root)
if err != nil {
return nil, err
}
if !within(realDir, realRoot) {
return nil, fmt.Errorf("the archive %s is taken from %q, which lies outside the module: it resolves to %s, and the module is %s", art.Name, art.From, realRoot, realDir)
}
return m.packInto(workspace, module, art.Name, root)
}
// pinnedArchive fetches the file an artifact is pinned to, checks it against the pin, lays it out as the artifact says
// (kept whole as `file`, or unpacked from `within`) and packs it.
func (m *Module) pinnedArchive(ctx context.Context, workspace, module string, art catalogue.Artifact) (any, error) {
file, err := m.pinnedFile(ctx, workspace, art)
if err != nil {
return nil, fmt.Errorf("%s: %w", art.Name, err)
}
laid := filepath.Join(m.Work, workspace, "upstream-"+art.Name)
if err := os.RemoveAll(laid); err != nil {
return nil, err
}
defer os.RemoveAll(laid)
if err := os.MkdirAll(laid, 0o755); err != nil {
return nil, err
}
if art.File != "" {
body, err := os.ReadFile(file)
if err != nil {
return nil, err
}
kept := filepath.Join(laid, art.File)
if err := os.WriteFile(kept, body, 0o644); err != nil {
return nil, err
}
// The mode is the archive's, never the account's umask: the digest is the same on every build node.
if err := os.Chmod(kept, 0o644); err != nil {
return nil, err
}
} else if err := unpackUpstream(file, laid, art.Within); err != nil {
return nil, fmt.Errorf("%s: %s (%s): %w", art.Name, art.URL, art.Version, err)
}
return m.packInto(workspace, module, art.Name, laid)
}
// pinnedFile is the path of the pinned file in the workspace: kept from an earlier build when its bytes are still the
// pin's, else fetched now and kept only when they are.
func (m *Module) pinnedFile(ctx context.Context, workspace string, art catalogue.Artifact) (string, error) {
want := strings.ToLower(art.SHA256)
dir := filepath.Join(m.Work, workspace, "upstream")
kept := filepath.Join(dir, want)
if got, err := sumOf(kept); err == nil {
if got == want {
return kept, nil
}
os.Remove(kept)
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, art.URL, nil)
if err != nil {
return "", err
}
client := *m.httpClient()
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if req.URL.Scheme != "https" {
return fmt.Errorf("%s redirected to %s, which is not https", via[0].URL, req.URL)
}
if len(via) >= 10 {
return errors.New("more than 10 redirects")
}
return nil
}
response, err := client.Do(request)
if err != nil {
return "", fmt.Errorf("cannot fetch %s: %w", art.URL, err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return "", fmt.Errorf("%s answered %s", art.URL, response.Status)
}
part, err := os.CreateTemp(dir, want+".*.part")
if err != nil {
return "", err
}
defer os.Remove(part.Name())
hash := sha256.New()
n, err := io.Copy(io.MultiWriter(part, hash), io.LimitReader(response.Body, maxUpstream+1))
if cerr := part.Close(); err == nil {
err = cerr
}
if err != nil {
return "", fmt.Errorf("fetching %s: %w", art.URL, err)
}
if n > maxUpstream {
return "", fmt.Errorf("%s is larger than %d bytes, more than a machine unpacks; nothing was kept", art.URL, maxUpstream)
}
if got := hex.EncodeToString(hash.Sum(nil)); got != want {
return "", fmt.Errorf("%s was pinned as sha256:%s and what it served is sha256:%s (%d bytes); nothing was kept. "+
"The address serves another file than the one reviewed: compare it, then move the pin in a reviewed change "+
"(novox/hq ADR 0316)", art.URL, want, got, n)
}
if err := os.Rename(part.Name(), kept); err != nil {
return "", err
}
return kept, nil
}
func sumOf(file string) (string, error) {
f, err := os.Open(file)
if err != nil {
return "", err
}
defer f.Close()
hash := sha256.New()
if _, err := io.Copy(hash, f); err != nil {
return "", err
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
// unpackUpstream unpacks a tar archive compressed with gzip or bzip2 into a directory: of its entries only those within
// `within` (all, when empty), with that prefix taken off. Only files and directories, and nothing outside: the
// node-engine unpacks nothing else. An xz archive is refused by name; the build agent reads it, this module does not
// yet (it is a later issue, not a quiet difference in the archive).
func unpackUpstream(file, into, within string) error {
f, err := os.Open(file)
if err != nil {
return err
}
defer f.Close()
buffered := bufio.NewReader(f)
magic, _ := buffered.Peek(6)
var stream io.Reader
switch {
case bytes.HasPrefix(magic, []byte{0x1f, 0x8b}):
zipped, err := gzip.NewReader(buffered)
if err != nil {
return err
}
defer zipped.Close()
stream = zipped
case bytes.HasPrefix(magic, []byte("BZh")):
stream = bzip2.NewReader(buffered)
case bytes.HasPrefix(magic, []byte{0xfd, '7', 'z', 'X', 'Z', 0x00}):
return errors.New("it is an xz archive, which this build module does not read yet; the build agent does")
default:
return errors.New("it is not a tar archive compressed with gzip or bzip2; a single file is kept whole by naming it in `file`")
}
return untar(stream, into, within)
}
func untar(stream io.Reader, into, within string) error {
reader := tar.NewReader(stream)
prefix := ""
if within != "" {
prefix = path.Clean(within) + "/"
}
var total int64
files, entries := 0, 0
for {
header, err := reader.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}
if entries++; entries > maxEntries {
return fmt.Errorf("it holds more than %d entries", maxEntries)
}
if header.Typeflag == tar.TypeXGlobalHeader {
continue
}
name := path.Clean(strings.TrimPrefix(header.Name, "./"))
if path.IsAbs(name) || name == ".." || strings.HasPrefix(name, "../") {
return fmt.Errorf("%s names a path outside the archive", header.Name)
}
if name == "." || name+"/" == prefix ||
(header.Typeflag == tar.TypeDir && strings.HasPrefix(prefix, name+"/")) {
continue
}
if prefix != "" {
if !strings.HasPrefix(name, prefix) {
return fmt.Errorf("%s is outside %s, the directory the artifact is taken from", header.Name, within)
}
name = strings.TrimPrefix(name, prefix)
}
target := filepath.Join(into, filepath.FromSlash(name))
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
case tar.TypeReg:
total += header.Size
if total > maxUpstream {
return fmt.Errorf("it unpacks to more than %d bytes, more than a machine unpacks", maxUpstream)
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
mode := os.FileMode(0o644)
if header.Mode&0o111 != 0 {
mode = 0o755
}
out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
if err != nil {
return err
}
if err := out.Chmod(mode); err != nil {
out.Close()
return err
}
if _, err := io.Copy(out, io.LimitReader(reader, header.Size)); err != nil {
out.Close()
return err
}
if err := out.Close(); err != nil {
return err
}
files++
default:
return fmt.Errorf("%s is a %c, and an archive carries only files and directories", header.Name, header.Typeflag)
}
}
if files == 0 {
if within != "" {
return fmt.Errorf("it holds no file within %s", within)
}
return errors.New("it holds no file")
}
return nil
}
// within says whether path is base or lies below it.
func within(base, path string) bool {
rel, err := filepath.Rel(base, path)
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
// resolveDeepest follows the links of the deepest part of p that exists and appends the rest, so a path not yet made
// is judged by where it would be made.
func resolveDeepest(p string) (string, error) {
rest := ""
for cur := filepath.Clean(p); ; {
if real, err := filepath.EvalSymlinks(cur); err == nil {
return filepath.Join(real, rest), nil
} else if !os.IsNotExist(err) {
return "", err
}
parent := filepath.Dir(cur)
if parent == cur {
return "", fmt.Errorf("no part of %s exists", p)
}
rest = filepath.Join(filepath.Base(cur), rest)
cur = parent
}
}
// insideModule refuses a path whose real place is not inside the module's directory, which is itself inside the
// workspace's tree: a link committed in the repository must not lead a build's writes or its pack out.
func (m *Module) insideModule(workspace, dir, path string) error {
realTree, err := filepath.EvalSymlinks(filepath.Join(m.Work, workspace, "tree"))
if err != nil {
return err
}
realDir, err := filepath.EvalSymlinks(dir)
if err != nil {
return err
}
if !within(realTree, realDir) {
return fmt.Errorf("the module directory resolves to %s, which lies outside the workspace's tree %s", realDir, realTree)
}
real, err := resolveDeepest(path)
if err != nil {
return err
}
if !within(realDir, real) {
return fmt.Errorf("%s resolves to %s, which lies outside the module %s", path, real, realDir)
}
return nil
}
@@ -0,0 +1,301 @@
package main
import (
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// Issue 1589: the build step packs an archive artifact (novox/hq ADR 0478 §1), as the build agent packs it
// (mesh-controller internal/builder: `pack` for a directory, upstreamArchive for a pinned file). The digests below are
// the build agent's own, captured from its code (testdata/captured/README).
const (
buildAgentDirectoryDigest = "sha256:9503d3f91a95be6d8faf8ec696054007cfefe4f5b4713dfc8f4284b026aa6443"
buildAgentWithinDigest = "sha256:9bacf4aab06ccfe90100aeb80b1bd94508a4d461b7e5665c599d7c5cb36c790f"
buildAgentFileDigest = "sha256:0d605adbdef9e41d8290720296ab13dc820861ab2ef36fafa9de02558d03716b"
releaseSHA256 = "2c9e5753c06902072422c4e9424154e8177684cc74af0bcc8dfe2bdf223a7e95"
)
// The sha256 of the same archives once gunzipped: the tar stream the build agent's pack writes. The gzip bytes, and so
// the digest, may belong to the Go that compressed them; the stream does not. The digests above are compared only under
// the Go they were captured with (go1.27.1, testdata/captured/README), the streams always: the first merge check of
// issue 1589 answered other digests than these under another toolchain, and the streams tell whether the cause is the
// compression or the content.
const (
buildAgentDirectoryStream = "f8af1d94b8753665e34d2f0ac2d576f8488dafd3eaba536ee86831848278d1b6"
buildAgentWithinStream = "294e2bec7548eddc73812dd218fce403cbd460d5d699f497e09f0965e7e607fb"
buildAgentFileStream = "055cffc2e4a8f3af82339dc597328594ff331a48167c6387efebd454f2f3428e"
capturedWithGo = "go1.27.1"
)
// sameAsBuildAgent says the archive at res.Path holds the tar stream the build agent's pack wrote, and, under the Go the
// digests were captured with, has its digest.
func sameAsBuildAgent(t *testing.T, res BuildResult, stream, digest string) {
t.Helper()
f, err := os.Open(res.Path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
z, err := gzip.NewReader(f)
if err != nil {
t.Fatal(err)
}
raw, err := io.ReadAll(z)
if err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(raw)
if got := hex.EncodeToString(sum[:]); got != stream {
t.Errorf("the tar stream is sha256:%s, the build agent's is sha256:%s", got, stream)
}
if strings.HasPrefix(runtime.Version(), capturedWithGo) && res.Digest != digest {
t.Errorf("digest %s, the build agent's is %s", res.Digest, digest)
}
}
// copyTree copies a directory, keeping modes.
func copyTree(t *testing.T, from, to string) {
t.Helper()
err := filepath.Walk(from, func(p string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, _ := filepath.Rel(from, p)
// A module.json in the repository would be taken for a module (issue 1527): fixtures keep it as module.json.fixture.
if filepath.Base(rel) == "module.json.fixture" {
rel = strings.TrimSuffix(rel, ".fixture")
}
target := filepath.Join(to, rel)
if info.IsDir() {
return os.MkdirAll(target, 0o755)
}
body, err := os.ReadFile(p)
if err != nil {
return err
}
// The modes a checkout gives depend on the umask of whoever checked it out; the fixture's are its executable bit
// alone, as git records it, so the digest does not depend on the machine the test runs on.
mode := os.FileMode(0o644)
if info.Mode().Perm()&0o100 != 0 {
mode = 0o755
}
if err := os.WriteFile(target, body, mode); err != nil {
return err
}
return os.Chmod(target, mode)
})
if err != nil {
t.Fatal(err)
}
}
// archiveModule is a module whose workspace ws1 holds the fixture tree of testdata/repos/archive; manifest, when given,
// replaces modules/demo/module.json.
func archiveModule(t *testing.T, manifest string) (*Module, *keptStates, *[]cmdRecord) {
t.Helper()
m, states, calls := buildModule(t, manifestWith(goBundle), "x")
tree := filepath.Join(m.Work, "ws1", "tree")
if err := os.RemoveAll(tree); err != nil {
t.Fatal(err)
}
copyTree(t, filepath.Join("testdata", "repos", "archive"), tree)
if manifest != "" {
if err := os.WriteFile(filepath.Join(tree, "modules", "demo", "module.json"), []byte(manifest), 0o644); err != nil {
t.Fatal(err)
}
}
return m, states, calls
}
// pinnedModule is an archive module whose artifact is pinned to path of a TLS server serving the release, under the
// name example.com (a manifest must say https and a named host); the module's client dials the server whatever the name.
func pinnedModule(t *testing.T, path, sha, extra string) (*Module, *keptStates) {
t.Helper()
body, err := os.ReadFile(filepath.Join("testdata", "upstream-release.tar.gz"))
if err != nil {
t.Fatal(err)
}
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/release.tar.gz" {
http.NotFound(w, r)
return
}
w.Write(body)
}))
t.Cleanup(srv.Close)
m, states, _ := archiveModule(t, pinned("https://example.com"+path, sha, extra))
client := srv.Client()
client.Transport.(*http.Transport).DialContext = func(ctx context.Context, network, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, network, srv.Listener.Addr().String())
}
m.HTTP = client
return m, states
}
// 1589/AC1: an archive of a directory of the module's tree has the digest the build agent's `pack` gives that tree,
// and the module runs no command for it.
func TestAnArchiveOfADirectoryHasTheBuildAgentsDigest(t *testing.T) {
m, states, calls := archiveModule(t, "")
res, st := runBuild(t, m, states, "files")
if st.State != delivery.StepPassed {
t.Fatalf("the step ended %s: %s", st.State, st.Reason)
}
sameAsBuildAgent(t, res, buildAgentDirectoryStream, buildAgentDirectoryDigest)
if res.Artifact != "files" || res.Size == 0 || res.Binary != "" {
t.Errorf("result = %+v", res)
}
if info, err := os.Stat(res.Path); err != nil || info.Size() != int64(res.Size) {
t.Errorf("the archive at %q: %v", res.Path, err)
}
if len(*calls) != 0 {
t.Errorf("an archive ran %d commands", len(*calls))
}
}
// An archive does not leave a stray directory in the module's tree: packing the same tree twice is one digest.
func TestPackingAnArchiveTwiceIsOneDigest(t *testing.T) {
m, states, _ := archiveModule(t, "")
a, _ := runBuild(t, m, states, "files")
b, _ := runBuild(t, m, states, "files")
if a.Digest == "" || a.Digest != b.Digest {
t.Errorf("%s and %s", a.Digest, b.Digest)
}
}
func TestAnArchiveFromOutsideTheModuleOrMissingFailsTheStep(t *testing.T) {
for from, want := range map[string]string{
"../../..": "outside",
"nothing": "nothing",
} {
m, states, _ := archiveModule(t, `{"module":"demo","version":"1","build":{"artifacts":[{"name":"files","kind":"archive","from":"`+from+`"}]}}`)
if _, st := runBuild(t, m, states, "files"); st.State != delivery.StepFailed || !strings.Contains(st.Reason, want) {
t.Errorf("from %q: %s %q", from, st.State, st.Reason)
}
}
}
func pinned(url, sha, extra string) string {
return `{"module":"demo","version":"1","build":{"artifacts":[{"name":"files","kind":"archive","url":"` + url +
`","sha256":"` + sha + `","version":"1.0"` + extra + `}]}}`
}
// 1589/AC2: a pinned file is fetched, checked against its sha256 and laid out as the artifact says; the digest is the
// build agent's for the same file.
func TestAPinnedArchiveIsUnpackedWithinAndHasTheBuildAgentsDigest(t *testing.T) {
m, states := pinnedModule(t, "/release.tar.gz", releaseSHA256, `,"within":"release/app"`)
res, st := runBuild(t, m, states, "files")
if st.State != delivery.StepPassed {
t.Fatalf("%s %q", st.State, st.Reason)
}
sameAsBuildAgent(t, res, buildAgentWithinStream, buildAgentWithinDigest)
}
func TestAPinnedFileKeptWholeHasTheBuildAgentsDigest(t *testing.T) {
m, states := pinnedModule(t, "/release.tar.gz", releaseSHA256, `,"file":"tool.bin"`)
res, st := runBuild(t, m, states, "files")
if st.State != delivery.StepPassed {
t.Fatalf("%s %q", st.State, st.Reason)
}
sameAsBuildAgent(t, res, buildAgentFileStream, buildAgentFileDigest)
}
// A file that is not the pin's is refused, naming both digests, and nothing is packed.
func TestAPinnedFileOfAnotherDigestIsRefused(t *testing.T) {
wrong := strings.Repeat("ab", 32)
m, states := pinnedModule(t, "/release.tar.gz", wrong, `,"within":"release/app"`)
_, st := runBuild(t, m, states, "files")
if st.State != delivery.StepFailed || !strings.Contains(st.Reason, "sha256:"+wrong) || !strings.Contains(st.Reason, "sha256:"+releaseSHA256) {
t.Errorf("the step ended %s: %q", st.State, st.Reason)
}
if entries, _ := os.ReadDir(filepath.Join(m.Work, "ws1", "artifacts")); len(entries) != 0 {
t.Errorf("an archive was left: %v", entries)
}
}
func TestAPinnedFileTheServerRefusesFailsTheStep(t *testing.T) {
m, states := pinnedModule(t, "/gone", releaseSHA256, `,"file":"x"`)
if _, st := runBuild(t, m, states, "files"); st.State != delivery.StepFailed || !strings.Contains(st.Reason, "404") {
t.Errorf("the step ended %s: %q", st.State, st.Reason)
}
}
// Entries outside `within` are refused, as the build agent refuses them.
func TestAPinnedArchiveWithEntriesOutsideWithinIsRefused(t *testing.T) {
m, states := pinnedModule(t, "/release.tar.gz", releaseSHA256, `,"within":"release/other"`)
if _, st := runBuild(t, m, states, "files"); st.State != delivery.StepFailed || !strings.Contains(st.Reason, "release/other") {
t.Errorf("the step ended %s: %q", st.State, st.Reason)
}
}
// Other kinds are still refused by name.
func TestAnImageArtifactIsStillRefusedByName(t *testing.T) {
m, states, _ := archiveModule(t, `{"module":"demo","version":"1","build":{"artifacts":[{"name":"files","kind":"image","from":"files"}]}}`)
if _, st := runBuild(t, m, states, "files"); st.State != delivery.StepFailed || !strings.Contains(st.Reason, "image") {
t.Errorf("the step ended %s: %q", st.State, st.Reason)
}
}
// A symlinked parent in the fetched tree must not take the archive outside the module (review of issue 1589): the
// link is made at run time, in a directory outside the workspace, and the step fails naming both paths.
func TestAnArchiveBehindASymlinkedParentIsRefused(t *testing.T) {
m, states, _ := archiveModule(t, `{"module":"demo","version":"1","build":{"artifacts":[{"name":"files","kind":"archive","from":"link/secret"}]}}`)
outside := t.TempDir()
if err := os.MkdirAll(filepath.Join(outside, "secret"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(outside, "secret", "key"), []byte("private"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(m.Work, "ws1", "tree", "modules", "demo", "link")); err != nil {
t.Fatal(err)
}
_, st := runBuild(t, m, states, "files")
if st.State != delivery.StepFailed || !strings.Contains(st.Reason, "outside") || !strings.Contains(st.Reason, outside) {
t.Errorf("the step ended %s: %q", st.State, st.Reason)
}
if entries, _ := os.ReadDir(filepath.Join(m.Work, "ws1", "artifacts")); len(entries) != 0 {
t.Errorf("an archive of outside files was left: %v", entries)
}
}
// A module directory that is itself a link out of the workspace's tree is refused: its root and its directory both
// resolve outside, so only the tree tells them apart (review of issue 1589).
func TestAnArchiveOfALinkedModuleDirectoryIsRefused(t *testing.T) {
m, states, _ := archiveModule(t, "")
outside := t.TempDir()
if err := os.MkdirAll(filepath.Join(outside, "files"), 0o755); err != nil {
t.Fatal(err)
}
os.WriteFile(filepath.Join(outside, "files", "key"), []byte("private"), 0o600)
os.WriteFile(filepath.Join(outside, "module.json"), []byte(`{"module":"evil","version":"1","build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`), 0o644)
if err := os.Symlink(outside, filepath.Join(m.Work, "ws1", "tree", "modules", "evil")); err != nil {
t.Fatal(err)
}
for _, tool := range m.Tools(m.StepTools()) {
if tool.Name == "build_go_build" {
if _, err := tool.Run(map[string]any{"workspace": "ws1", "module": "modules/evil", "artifact": "files"}); err != nil {
t.Fatal(err)
}
}
}
st := states.waitEnd(t)
if st.State != delivery.StepFailed || !strings.Contains(st.Reason, "outside") || !strings.Contains(st.Reason, outside) {
t.Errorf("the step ended %s: %q", st.State, st.Reason)
}
if entries, _ := os.ReadDir(filepath.Join(m.Work, "ws1", "artifacts")); len(entries) != 0 {
t.Errorf("an archive of outside files was left: %v", entries)
}
}
@@ -40,7 +40,7 @@ type BuildResult struct {
func (m *Module) buildTool() StepTool {
return StepTool{
Name: "build_go_build",
Description: "Build one Go bundle artifact of a module of a fetched workspace with the build agent's recipe, by the " +
Description: "Build one Go bundle or archive artifact of a module of a fetched workspace with the build agent's recipe, by the " +
"installed Go and no network, and pack it. Answers a step id at once; the step ends on its key with the " +
"artifact's digest, size and the archive's path.",
Input: map[string]any{
@@ -105,8 +105,11 @@ func (m *Module) build(ctx context.Context, args map[string]any) (any, error) {
if art == nil {
return nil, fmt.Errorf("the module %s has no artifact %q (it has %s)", manifest.Module, name, strings.Join(names, ", "))
}
if art.Kind == "archive" {
return m.buildArchive(ctx, workspace, module, dir, *art)
}
if art.Kind != "bundle" || art.Language != "go" {
return nil, fmt.Errorf("the artifact %s of %s is a %s of language %q: this module builds Go bundles", name, manifest.Module, art.Kind, art.Language)
return nil, fmt.Errorf("the artifact %s of %s is a %s of language %q: this module builds Go bundles and archives", name, manifest.Module, art.Kind, art.Language)
}
if strings.TrimSpace(art.From) == "" {
return nil, fmt.Errorf("the artifact %s of %s says nothing it is built from", name, manifest.Module)
@@ -114,6 +117,9 @@ func (m *Module) build(ctx context.Context, args map[string]any) (any, error) {
// Its own output directory, made empty: a build of the same tree twice is one build.
out := filepath.Join(dir, outDir, art.Name)
if err := m.insideModule(workspace, dir, out); err != nil {
return nil, fmt.Errorf("the output directory of %s: %w", art.Name, err)
}
if err := os.RemoveAll(out); err != nil {
return nil, err
}
@@ -130,19 +136,33 @@ func (m *Module) build(ctx context.Context, args map[string]any) (any, error) {
if r := m.Run(ctx, m.buildEnv(dir), "go", cmd...); r.Status != 0 || r.Err != "" {
return nil, fmt.Errorf("go build of %s: %s", art.Name, causeLine(r.Err+"\n"+r.Stderr+"\n"+r.Stdout))
}
archive, digest, err := buildstep.Pack(out)
if err := m.insideModule(workspace, dir, out); err != nil {
return nil, fmt.Errorf("the output directory of %s: %w", art.Name, err)
}
res, err := m.packInto(workspace, module, art.Name, out)
if err != nil {
return nil, fmt.Errorf("packing %s: %w", art.Name, err)
return nil, err
}
res.Binary = binary
return res, nil
}
// packInto packs a directory with mesh-sdk's Pack, as the build agent packs every artifact, and leaves the archive in
// the workspace's artifacts directory.
func (m *Module) packInto(workspace, module, artifact, root string) (BuildResult, error) {
archive, digest, err := buildstep.Pack(root)
if err != nil {
return BuildResult{}, fmt.Errorf("packing %s: %w", artifact, err)
}
artifacts := filepath.Join(m.Work, workspace, "artifacts")
if err := os.MkdirAll(artifacts, 0o700); err != nil {
return nil, err
return BuildResult{}, err
}
path := filepath.Join(artifacts, strings.ReplaceAll(module, "/", "_")+"."+art.Name+".tar.gz")
path := filepath.Join(artifacts, strings.ReplaceAll(module, "/", "_")+"."+artifact+".tar.gz")
if err := os.WriteFile(path, archive, 0o600); err != nil {
return nil, err
return BuildResult{}, err
}
return BuildResult{Artifact: art.Name, Binary: binary, Digest: digest, Size: len(archive), Path: path}, nil
return BuildResult{Artifact: artifact, Digest: digest, Size: len(archive), Path: path}, nil
}
// buildEnv is the environment of the compile: the build agent's (CGO_ENABLED=0, GOFLAGS) with the installed go
@@ -175,7 +175,7 @@ func TestTheArchiveHoldsOneExecutableAndTwoBuildsAreOneDigest(t *testing.T) {
func TestAnArtifactOfAnotherLanguageFailsTheStepNamingIt(t *testing.T) {
for artifact, want := range map[string]string{
`{"name":"tools","kind":"bundle","language":"typescript","entrypoints":["index.js"],"loads":["index.js"]}`: `language "typescript"`,
`{"name":"tools","kind":"archive","from":"files"}`: "archive",
`{"name":"tools","kind":"image","from":"files"}`: "image",
} {
m, states, calls := buildModule(t, manifestWith(artifact), "x")
_, st := runBuild(t, m, states, "tools")
@@ -215,3 +215,27 @@ func TestAModuleDirectoryOutsideTheWorkspaceIsRefused(t *testing.T) {
t.Errorf("the step ended %s after %d commands", st.State, len(*calls))
}
}
// Review of issue 1589: the output directory lies in the module; a committed `.mesh-build` that is a link out of it
// must not take the build's writes, removals or the pack outside.
func TestABuildOutputDirectoryLinkedOutsideTheModuleIsRefused(t *testing.T) {
m, states, calls := buildModule(t, manifestWith(goBundle), "the program")
outside := t.TempDir()
if err := os.MkdirAll(filepath.Join(outside, "tools"), 0o755); err != nil {
t.Fatal(err)
}
kept := filepath.Join(outside, "tools", "precious")
if err := os.WriteFile(kept, []byte("mine"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(m.Work, "ws1", "tree", "modules", "demo", ".mesh-build")); err != nil {
t.Fatal(err)
}
_, st := runBuild(t, m, states, "tools")
if st.State != delivery.StepFailed || !strings.Contains(st.Reason, "outside") || !strings.Contains(st.Reason, outside) || len(*calls) != 0 {
t.Errorf("the step ended %s: %q after %d commands", st.State, st.Reason, len(*calls))
}
if body, err := os.ReadFile(kept); err != nil || string(body) != "mine" {
t.Errorf("a file outside the module was touched: %q, %v", body, err)
}
}
@@ -3,6 +3,7 @@ package main
import (
"context"
"fmt"
"net/http"
"time"
"git.novox.be/novox/mesh-sdk/go/buildstep"
@@ -36,6 +37,9 @@ type Module struct {
// both outside the account's home (ADR 0466 §1); Home is the account's home.
Run forgekey.Runner
Home, Work, Cache string
// HTTP fetches the files an archive artifact is pinned to (url and sha256); a client of its own with a fifteen-minute
// timeout when nil. A test serves its own.
HTTP *http.Client
// UnitBound is the unit level's bound; zero is UnitBound. A test lowers it to see a level over its bound.
UnitBound time.Duration
// IntegrationBounds are the integration level's bounds; zero values are IntegrationSetup and IntegrationRun.
@@ -8,3 +8,19 @@ outside the tree as go list describes it.
toolchain-go-entry.txt is lines 155-191 of mesh-control's internal/builder/toolchain.go at origin/main 073ccc29 (fetched
2026-10-20): the Go entry of today's build agent's recipe, which the build step's flags are compared with.
repos/archive is a module tree (modules/demo, an archive artifact "files" from `files`, with an executable and a nested
file). Its digest, sha256:9503d3f9...6443, is what mesh-controller's `pack` (internal/builder/builder.go, the function
build-agent packs every archive with) answers for modules/demo/files: captured on 2026-10-21 for issue 1589 by a
throwaway test beside `pack` in a detached worktree of origin/main c8539bf3 that called `pack` on that directory and
printed the sha256 of its bytes (not kept).
upstream-release.tar.gz is a tar made with `tar --sort=name --mtime=@0 --owner=0 --group=0 -czf` of release/app/x.txt and
release/app/bin/tool (mode 755). Its pinned sha256 is 2c9e5753...7e95 (`sha256sum`). The digests of the archive packed from
it are what build-agent's `unpackUpstream(file, dir, "release/app")` followed by `pack(dir)` answers (9bacf4aa...790f),
and what `pack` answers for a directory holding the file as `tool.bin` (`file: tool.bin`; 0d605adb...716b), captured the
same way and the same day.
The tar streams (the archives gunzipped) of the three archives above, captured the same way (build-agent's `pack`, then
gunzip and sha256) at origin/main 0205b8f7 on 2026-10-21: directory f8af1d94...d1b6, within 294e2bec...07fb, file
055cffc2...428e. The digests above hold the gzip bytes of go1.27.1. The first try of issue 1589 on the merge check's build node answered
other digests; the tests therefore compare the stream always and the digest only under go1.27.1.
@@ -0,0 +1,2 @@
#!/bin/sh
echo hi
@@ -0,0 +1 @@
{"module":"demo","version":"1","build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}