diff --git a/.gitea/workflows/private-modules.yaml b/.gitea/workflows/private-modules.yaml new file mode 100644 index 0000000..6125e97 --- /dev/null +++ b/.gitea/workflows/private-modules.yaml @@ -0,0 +1,14 @@ +name: Private module tooling +on: + pull_request: + push: + branches: [main] +jobs: + contract: + runs-on: fritzlab + steps: + - uses: actions/checkout@v4 + - name: Canonical export and native offline boundaries + run: | + python3 -m unittest discover -s tests + docker build --network none --file tests/private-modules.Dockerfile tools/private-modules diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/README.md b/README.md index ab1a50a..12da3c5 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,45 @@ PR build that pulls a private base image still needs a registry token limited to the `read:package` capability; public-base builds need no token. The action logs in as `ci-bot`, so the token must be issued to that account. +## Private Go module builds + +`tools/private-modules/` owns the stdlib-only offline generator and verifier for +native Go file-proxy bundles. Consumers export exact committed source bytes so +their first check needs no Git, network, module dependency, or credential. +Changes belong here; consumers must not edit their generated copies. + +From this checkout, export a reviewed full commit ID into a service checkout: + +```sh +python3 tools/export-private-modules.py /path/to/service --revision FULL_COMMIT_ID +python3 tools/export-private-modules.py /path/to/service --revision FULL_COMMIT_ID --check +``` + +The export includes source revision/path/digest metadata and a SHA256 lock. +Service Make and Docker builds run `sha256sum -c tools/private-modules.sha256` +before `GO111MODULE=off GOTOOLCHAIN=local GOFLAGS= go run tools/private-modules.go`. +Reviewers can reproduce `--check` from that pinned source commit. The local hash +check detects accidental drift; Git review establishes the trusted source pin. + +Populate selected private versions through the configured authenticated Go +client once, then run the generated command with `-write` to copy unchanged +`.info`, `.mod`, and `.zip` cache artifacts into `third_party/go-proxy`. The +generator itself enforces offline resolution and a local toolchain. It preserves +the complete original archives, including any licenses and notices. Normal Go +downloads still validate their content against the consumer's `go.sum`. +The generated proxy directory has an exact build-only nested `go.mod` marker. +Native Go module packaging excludes that directory from provider releases, so +downstream consumers never recursively bundle another provider's build inputs. +The checker requires the marker, and a native local-Git archive test proves the +exclusion without permitting Git network protocols. + +Consumer resolution uses `GONOPROXY=none`, `GONOSUMDB=code.fritzlab.net`, +`GOFLAGS=-mod=readonly`, `GOTOOLCHAIN=local`, and +`GOPROXY=file:///absolute/service/third_party/go-proxy,https://proxy.golang.org`. +Run the checker before and after downloads; its exact go.mod/go.sum fingerprint +rejects stale dependency closures. Public modules stay on the public proxy. +Registry publication remains a separate authenticated operation. + ## Usage ```yaml @@ -57,6 +96,20 @@ input for public bases. |---|---| | `tag` | Numeric tag assigned (= `github.run_number`). | +## Private module contract validation + +The private-module contract workflow runs its Go race tests in the digest-pinned +standard Go compiler image in `tests/private-modules.Dockerfile`. The Fritzlab +runner doesn't provide GCC. Only `tools/private-modules` enters that build context; +the test runs with networking and module downloads disabled. The public compiler +image must already be cached or obtainable through the existing image pull path. +This check doesn't publish an image or change runner configuration. + +```sh +python3 -m unittest discover -s tests +docker build --network none --file tests/private-modules.Dockerfile tools/private-modules +``` + ## Smoke test patterns Override entrypoint for a binary that expects no args: diff --git a/tests/private-modules.Dockerfile b/tests/private-modules.Dockerfile new file mode 100644 index 0000000..31edd39 --- /dev/null +++ b/tests/private-modules.Dockerfile @@ -0,0 +1,7 @@ +FROM golang:1.27.0-trixie@sha256:df98008ecd2b0ecc9f0a94d1b07e3564a9c92b555369b33d9b5f60d0765b2db7 +WORKDIR /contract +# The runner intentionally needn't provide a C compiler for this race check. +# The build context contains only the canonical Go tooling, without Git state. +COPY *.go ./ +ENV CGO_ENABLED=1 GO111MODULE=off GOTOOLCHAIN=local GOFLAGS= GOPROXY=off GOSUMDB=off +RUN go test -race -count=1 ./ diff --git a/tests/test_private_modules_export.py b/tests/test_private_modules_export.py new file mode 100644 index 0000000..f9f0a1d --- /dev/null +++ b/tests/test_private_modules_export.py @@ -0,0 +1,91 @@ +import importlib.util +import pathlib +import os +import json +import subprocess +import tempfile +import unittest +import zipfile + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location("exporter", ROOT / "tools/export-private-modules.py") +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class ExportTest(unittest.TestCase): + def test_native_go_archive_excludes_nested_build_artifacts(self): + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + source = root / "source" + source.mkdir() + module = "example.invalid/fixture.git" + (source / "go.mod").write_text("module " + module + "\n\ngo 1.27.0\n") + (source / "provider.go").write_text("package fixture\n") + boundary = source / "third_party/go-proxy" + boundary.mkdir(parents=True) + (boundary / "go.mod").write_text("module example.invalid/build-artifacts\n\ngo 1.27.0\n") + (boundary / "provider.zip").write_bytes(b"must not enter the provider module archive") + subprocess.run(["git", "init", "-q", str(source)], check=True) + subprocess.run(["git", "-C", str(source), "add", "."], check=True) + subprocess.run(["git", "-C", str(source), "-c", "user.name=Fixture", + "-c", "user.email=fixture@example.invalid", "commit", "-qm", "fixture"], check=True) + subprocess.run(["git", "-C", str(source), "tag", "v0.1.0"], check=True) + config = root / "gitconfig" + config.write_text('[url "' + source.as_uri() + '"]\n' + '\tinsteadOf = https://example.invalid/fixture\n') + # Let Go select the synthetic VCS URL; Git still permits only the + # rewritten local file protocol, with no network scheme allowed. + env = dict(os.environ, GO111MODULE="on", GOTOOLCHAIN="local", GOFLAGS="", GOWORK="off", + GOPROXY="direct", GONOPROXY="none", GOSUMDB="off", GOVCS="*:git", + GOINSECURE="example.invalid", + GOMODCACHE=str(root / "cache"), GIT_CONFIG_GLOBAL=str(config), + GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_COUNT="0", GIT_ALLOW_PROTOCOL="file") + probe = subprocess.run(["git", "ls-remote", "https://example.invalid/fixture"], env=env, capture_output=True) + self.assertEqual(probe.returncode, 0, probe.stderr.decode()) + # Native Git accepts only the local file protocol: a missed rewrite + # cannot turn this proof into a network or credential lookup. + result = subprocess.run(["go", "mod", "download", "-json", module + "@v0.1.0"], + cwd=root, env=env, capture_output=True) + self.assertEqual(result.returncode, 0, result.stdout.decode() + result.stderr.decode()) + archive = json.loads(result.stdout)["Zip"] + with zipfile.ZipFile(archive) as zipped: + names = zipped.namelist() + self.assertIn(module + "@v0.1.0/provider.go", names) + self.assertFalse(any("third_party/go-proxy" in name for name in names)) + + def test_committed_bytes_and_provenance_are_exact(self): + with tempfile.TemporaryDirectory() as temporary: + base = pathlib.Path(temporary) + source, target = base / "source", base / "target" + source.mkdir() + target.mkdir() + subprocess.run(["git", "init", "-q", str(source)], check=True) + for path in MODULE.FILES: + file = source / path + file.parent.mkdir(parents=True, exist_ok=True) + file.write_text("package main\n") + subprocess.run(["git", "-C", str(source), "add", "."], check=True) + subprocess.run(["git", "-C", str(source), "-c", "user.name=Fixture", + "-c", "user.email=fixture@example.invalid", "commit", "-qm", "fixture"], check=True) + revision = MODULE.committed(source, "rev-parse", "HEAD").decode().strip() + (source / next(iter(MODULE.FILES))).write_text("dirty worktree must be ignored") + MODULE.export(source, target, revision) + MODULE.export(source, target, revision, True) + subprocess.run(["sha256sum", "-c", "tools/private-modules.sha256"], + cwd=target, check=True, stdout=subprocess.DEVNULL) + destination = target / "tools/private-modules.go" + self.assertEqual(destination.read_text(), "package main\n") + destination.write_text("changed") + with self.assertRaises(ValueError): + MODULE.export(source, target, revision, True) + destination.unlink() + destination.symlink_to(source / next(iter(MODULE.FILES))) + with self.assertRaises(ValueError): + MODULE.export(source, target, revision) + with self.assertRaises(ValueError): + MODULE.export(source, target, "HEAD") + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/export-private-modules.py b/tools/export-private-modules.py new file mode 100644 index 0000000..14e5f65 --- /dev/null +++ b/tools/export-private-modules.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Export exact committed offline-bootstrap tooling; never download anything.""" +import argparse +import hashlib +import json +import pathlib +import re +import subprocess + +SOURCE = "https://code.fritzlab.net/action/image-build" +FILES = { + "tools/private-modules/main.go": "tools/private-modules.go", + "tools/private-modules/main_test.go": "tools/private-modules_test.go", +} + + +def committed(root, *args): + return subprocess.check_output(["git", "-C", str(root), *args]) + + +def export(root, target, revision, check=False): + if not re.fullmatch(r"[0-9a-f]{40}", revision): + raise ValueError("source revision must be a full Git commit ID") + resolved = committed(root, "rev-parse", revision + "^{commit}").decode().strip() + if resolved != revision: + raise ValueError("source revision is not a commit") + result = {} + entries = [] + for source, destination in FILES.items(): + tree = committed(root, "ls-tree", revision, "--", source).decode() + if not tree.startswith("100644 blob ") or not tree.endswith("\t" + source + "\n"): + raise ValueError("source must be one regular committed file") + data = committed(root, "show", revision + ":" + source) + if not data or len(data) > 128 * 1024: + raise ValueError("source exceeds its bounded export") + result[destination] = data + entries.append({"source": source, "destination": destination, + "sha256": hashlib.sha256(data).hexdigest()}) + result["tools/private-modules.source.json"] = (json.dumps({ + "version": 1, "repository": SOURCE, "revision": revision, "files": entries, + }, indent=2) + "\n").encode() + result["tools/private-modules.sha256"] = "".join( + hashlib.sha256(data).hexdigest() + " " + path + "\n" + for path, data in sorted(result.items()) + ).encode() + target = target.resolve(strict=True) + tools_dir = target / "tools" + if tools_dir.is_symlink(): + raise ValueError("target tools directory must not be a symlink") + if not check: + tools_dir.mkdir(exist_ok=True) + for path, data in result.items(): + destination = target / path + if destination.is_symlink(): + raise ValueError("target file must not be a symlink") + if check: + if destination.read_bytes() != data: + raise ValueError("export differs from pinned canonical source: " + path) + else: + destination.write_bytes(data) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("target", type=pathlib.Path) + parser.add_argument("--revision", required=True) + parser.add_argument("--check", action="store_true") + args = parser.parse_args() + export(pathlib.Path(__file__).resolve().parents[1], args.target, + args.revision, args.check) + + +if __name__ == "__main__": + main() diff --git a/tools/private-modules/main.go b/tools/private-modules/main.go new file mode 100644 index 0000000..eb63867 --- /dev/null +++ b/tools/private-modules/main.go @@ -0,0 +1,301 @@ +// Command private-modules maintains exact private Go proxy artifacts from an +// already authenticated local module cache. It never downloads dependencies. +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" +) + +const bundleRoot = "third_party/go-proxy" +const privatePrefix = "code.fritzlab.net/" +const archiveBoundary = "module code.fritzlab.net/action/image-build/private-module-artifacts\n\ngo 1.27.0\n" + +type artifact struct { + Path string `json:"path"` + SHA256 string `json:"sha256"` + Size int64 `json:"size_bytes"` +} +type module struct { + Path string `json:"path"` + Version string `json:"version"` + Files []artifact `json:"files"` +} +type manifest struct { + Version int `json:"version"` + GoModSHA256 string `json:"go_mod_sha256"` + GoSumSHA256 string `json:"go_sum_sha256"` + Modules []module `json:"modules"` +} + +func digest(b []byte) string { s := sha256.Sum256(b); return hex.EncodeToString(s[:]) } +func inputDigest(name string) (string, error) { + b, e := os.ReadFile(name) + if e != nil { + return "", e + } + return digest(b), nil +} +func nativeGo(args ...string) ([]byte, error) { + c := exec.Command("go", args...) + c.Env = append(os.Environ(), "GOPROXY=off", "GONOPROXY=none", "GOTOOLCHAIN=local", "GOSUMDB=off", "GO111MODULE=on", "GOFLAGS=", "GOWORK=off") + return c.Output() +} +func generate() error { + m := manifest{Version: 1, Modules: []module{}} + var err error + if m.GoModSHA256, err = inputDigest("go.mod"); err != nil { + return err + } + if m.GoSumSHA256, err = inputDigest("go.sum"); err != nil { + return err + } + cache, err := nativeGo("env", "GOMODCACHE") + if err != nil { + return err + } + cacheRoot := filepath.Join(strings.TrimSpace(string(cache)), "cache", "download") + raw, err := nativeGo("list", "-m", "-json", "all") + if err != nil { + return fmt.Errorf("module metadata must already be cached: %w", err) + } + parent := filepath.Dir(bundleRoot) + if err = os.MkdirAll(parent, 0755); err != nil { + return err + } + temp, err := os.MkdirTemp(parent, ".go-proxy-") + if err != nil { + return err + } + defer func() { _ = os.RemoveAll(temp) }() + if err := os.WriteFile(filepath.Join(temp, "go.mod"), []byte(archiveBoundary), 0444); err != nil { + return err + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + var total int64 + for { + var src struct { + Path, Version, GoMod string + Main bool + Replace json.RawMessage + } + if err = decoder.Decode(&src); errors.Is(err, io.EOF) { + break + } else if err != nil { + return err + } + if src.Main || !strings.HasPrefix(src.Path, privatePrefix) { + continue + } + if len(src.Replace) != 0 || src.Version == "" || len(m.Modules) >= 64 { + return errors.New("private module must have exact unreplaced version") + } + mod := module{Path: src.Path, Version: src.Version, Files: []artifact{}} + stem, err := cacheStem(cacheRoot, src.Path, src.Version, src.GoMod) + if err != nil { + return err + } + for _, ext := range []string{".info", ".mod", ".zip"} { + name := stem + ext + source := filepath.Join(cacheRoot, name) + info, err := os.Lstat(source) + if err != nil || !info.Mode().IsRegular() || info.Size() > 64<<20 { + return errors.New("private module artifact absent or invalid; populate authenticated cache first") + } + total += info.Size() + if total > 256<<20 { + return errors.New("private module bundle too large") + } + b, err := os.ReadFile(source) + if err != nil { + return err + } + target := filepath.Join(temp, name) + if err = os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return err + } + if err = os.WriteFile(target, b, 0444); err != nil { + return err + } + mod.Files = append(mod.Files, artifact{Path: filepath.ToSlash(name), SHA256: digest(b), Size: int64(len(b))}) + } + m.Modules = append(m.Modules, mod) + } + sort.Slice(m.Modules, func(i, j int) bool { return m.Modules[i].Path < m.Modules[j].Path }) + if len(m.Modules) == 0 { + return errors.New("no private modules") + } + encoded, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err + } + if err = os.WriteFile(filepath.Join(temp, "manifest.json"), append(encoded, '\n'), 0444); err != nil { + return err + } + // This path is exclusively generated. Refuse to replace an unrelated tree. + if _, err = os.Stat(bundleRoot); err == nil { + if _, err = os.Stat(filepath.Join(bundleRoot, "manifest.json")); err != nil { + return errors.New("refuse to replace unrecognized bundle") + } + if err = os.RemoveAll(bundleRoot); err != nil { + return err + } + } else if !os.IsNotExist(err) { + return err + } + return os.Rename(temp, bundleRoot) +} + +var modulePath = regexp.MustCompile(`^code[.]fritzlab[.]net/[A-Za-z0-9._~-]+(/[A-Za-z0-9._~-]+)+$`) +var moduleVersion = regexp.MustCompile(`^v[0-9]+[.][0-9]+[.][0-9]+(-[0-9A-Za-z.-]+)?([+]incompatible)?$`) + +// Standard Go proxy escaping replaces each ASCII capital with !lowercase. +func moduleStem(path, version string) (string, error) { + if len(path) > 512 || len(version) > 256 || !modulePath.MatchString(path) || !moduleVersion.MatchString(version) { + return "", errors.New("invalid private module identity") + } + for _, part := range strings.Split(path, "/") { + if part == "." || part == ".." { + return "", errors.New("invalid private module path") + } + } + escape := func(value string) string { + var out strings.Builder + for _, r := range value { + if r >= 'A' && r <= 'Z' { + out.WriteByte('!') + r += 'a' - 'A' + } + out.WriteRune(r) + } + return out.String() + } + return escape(path) + "/@v/" + escape(version), nil +} + +func cacheStem(cacheRoot, path, version, goMod string) (string, error) { + stem, err := moduleStem(path, version) + if err != nil { + return "", err + } + // Lazy module graphs omit GoMod for selected but unexpanded modules. + // Their canonical cache path is still determined by exact identity. + if goMod != "" { + rel, err := filepath.Rel(cacheRoot, goMod) + if err != nil || filepath.ToSlash(rel) != stem+".mod" { + return "", errors.New("native artifact path differs from module identity") + } + } + return stem, nil +} + +func safePath(p string) bool { + return p != "" && filepath.Clean(p) == p && !filepath.IsAbs(p) && p != ".." && !strings.HasPrefix(p, "../") && !strings.ContainsAny(p, "\\\x00") +} +func check() error { + raw, err := os.ReadFile(filepath.Join(bundleRoot, "manifest.json")) + if err != nil || len(raw) > 1<<20 { + return errors.New("private module manifest missing or oversized") + } + var m manifest + d := json.NewDecoder(bytes.NewReader(raw)) + d.DisallowUnknownFields() + if err = d.Decode(&m); err != nil || m.Version != 1 || len(m.Modules) == 0 || len(m.Modules) > 64 { + return errors.New("invalid private module manifest") + } + var trailing any + if err = d.Decode(&trailing); !errors.Is(err, io.EOF) { + return errors.New("trailing private module manifest data") + } + mod, err := inputDigest("go.mod") + if err != nil { + return err + } + sum, err := inputDigest("go.sum") + if err != nil { + return err + } + if mod != m.GoModSHA256 || sum != m.GoSumSHA256 { + return errors.New("private module closure stale; regenerate after go.mod/go.sum changes") + } + boundary, err := os.ReadFile(filepath.Join(bundleRoot, "go.mod")) + if err != nil || string(boundary) != archiveBoundary { + return errors.New("private module archive boundary missing or modified") + } + expected := map[string]bool{"manifest.json": true, "go.mod": true} + last := "" + var total int64 + for _, m := range m.Modules { + if !strings.HasPrefix(m.Path, privatePrefix) || m.Path <= last || m.Version == "" || len(m.Files) != 3 { + return errors.New("invalid private module closure") + } + last = m.Path + stem, err := moduleStem(m.Path, m.Version) + if err != nil { + return err + } + for i, f := range m.Files { + if !safePath(f.Path) || expected[f.Path] || f.Path != stem+[]string{".info", ".mod", ".zip"}[i] || f.Size < 0 || f.Size > 64<<20 { + return errors.New("invalid module artifact") + } + total += f.Size + if total > 256<<20 { + return errors.New("private module bundle too large") + } + full := filepath.Join(bundleRoot, f.Path) + info, e := os.Lstat(full) + if e != nil || !info.Mode().IsRegular() || info.Size() != f.Size { + return errors.New("private module artifact missing or invalid") + } + b, e := os.ReadFile(full) + if e != nil || digest(b) != f.SHA256 { + return errors.New("private module artifact digest mismatch") + } + expected[f.Path] = true + } + } + err = filepath.WalkDir(bundleRoot, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.Type()&os.ModeSymlink != 0 { + return errors.New("symlink in private module bundle") + } + if entry.IsDir() { + return nil + } + rel, e := filepath.Rel(bundleRoot, path) + if e != nil || !expected[filepath.ToSlash(rel)] { + return errors.New("unexpected private module artifact") + } + return nil + }) + return err +} +func main() { + write := flag.Bool("write", false, "regenerate from authenticated local module cache (no network)") + flag.Parse() + var err error + if *write { + err = generate() + } else { + err = check() + } + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Println("private module artifacts verified") +} diff --git a/tools/private-modules/main_test.go b/tools/private-modules/main_test.go new file mode 100644 index 0000000..c94cd1c --- /dev/null +++ b/tools/private-modules/main_test.go @@ -0,0 +1,197 @@ +package main + +import ( + "archive/zip" + "bytes" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "testing" +) + +func TestBundleRejectsStaleMissingModifiedAndUnexpectedArtifacts(t *testing.T) { + t.Chdir(t.TempDir()) + mustFixture(t, os.WriteFile("go.mod", []byte("module example.invalid/test\n"), 0600)) + mustFixture(t, os.WriteFile("go.sum", []byte("recorded sum\n"), 0600)) + mod, _ := inputDigest("go.mod") + sum, _ := inputDigest("go.sum") + m := manifest{Version: 1, GoModSHA256: mod, GoSumSHA256: sum, Modules: []module{{Path: "code.fritzlab.net/agenthub/example", Version: "v0.1.0"}}} + for _, ext := range []string{".info", ".mod", ".zip"} { + name := "code.fritzlab.net/agenthub/example/@v/v0.1.0" + ext + b := []byte("canonical artifact " + ext) + mustFixture(t, os.MkdirAll(filepath.Dir(filepath.Join(bundleRoot, name)), 0700)) + mustFixture(t, os.WriteFile(filepath.Join(bundleRoot, name), b, 0600)) + m.Modules[0].Files = append(m.Modules[0].Files, artifact{Path: name, Size: int64(len(b)), SHA256: digest(b)}) + } + b, _ := json.Marshal(m) + mustFixture(t, os.WriteFile(filepath.Join(bundleRoot, "manifest.json"), b, 0600)) + mustFixture(t, os.WriteFile(filepath.Join(bundleRoot, "go.mod"), []byte(archiveBoundary), 0600)) + if err := check(); err != nil { + t.Fatal(err) + } + for _, boundary := range []string{"", "module unexpected.invalid/nested\n"} { + mustFixture(t, os.WriteFile(filepath.Join(bundleRoot, "go.mod"), []byte(boundary), 0600)) + if check() == nil { + t.Fatal("missing or changed archive boundary accepted") + } + } + mustFixture(t, os.WriteFile(filepath.Join(bundleRoot, "go.mod"), []byte(archiveBoundary), 0600)) + originalPath, originalVersion := m.Modules[0].Path, m.Modules[0].Version + for _, change := range []string{"path", "version"} { + if change == "path" { + m.Modules[0].Path = "code.fritzlab.net/agenthub/other" + } else { + m.Modules[0].Version = "v0.2.0" + } + changed, _ := json.Marshal(m) + mustFixture(t, os.WriteFile(filepath.Join(bundleRoot, "manifest.json"), changed, 0600)) + if check() == nil { + t.Fatal("artifact accepted under different module identity") + } + m.Modules[0].Path, m.Modules[0].Version = originalPath, originalVersion + } + mustFixture(t, os.WriteFile(filepath.Join(bundleRoot, "manifest.json"), b, 0600)) + mustFixture(t, os.WriteFile("go.mod", []byte("changed graph\n"), 0600)) + if check() == nil { + t.Fatal("stale dependency closure accepted") + } + mustFixture(t, os.WriteFile("go.mod", []byte("module example.invalid/test\n"), 0600)) + path := filepath.Join(bundleRoot, m.Modules[0].Files[2].Path) + original, _ := os.ReadFile(path) + mustFixture(t, os.WriteFile(path, []byte("corrupted zip"), 0600)) + if check() == nil { + t.Fatal("modified artifact accepted") + } + mustFixture(t, os.Remove(path)) + if check() == nil { + t.Fatal("missing artifact accepted") + } + mustFixture(t, os.WriteFile(path, original, 0600)) + extra := filepath.Join(bundleRoot, "extra") + mustFixture(t, os.WriteFile(extra, []byte("unlisted"), 0600)) + if check() == nil { + t.Fatal("unlisted artifact accepted") + } + mustFixture(t, os.Remove(extra)) + mustFixture(t, os.Symlink("manifest.json", extra)) + if check() == nil { + t.Fatal("symlink accepted") + } +} + +func TestNativeGoSumRejectsChangedModuleArtifact(t *testing.T) { + root := t.TempDir() + proxy := filepath.Join(root, "proxy") + modulePath, version := "code.fritzlab.net/fixture/module", "v0.1.0" + base := filepath.Join(proxy, modulePath, "@v", version) + if err := os.MkdirAll(filepath.Dir(base), 0700); err != nil { + t.Fatal(err) + } + mod := []byte("module " + modulePath + "\n\ngo 1.27.0\n") + mustFixture(t, os.WriteFile(base+".mod", mod, 0600)) + mustFixture(t, os.WriteFile(base+".info", []byte(`{"Version":"v0.1.0","Time":"2026-01-01T00:00:00Z"}`), 0600)) + var archive bytes.Buffer + zw := zip.NewWriter(&archive) + entry, err := zw.Create(modulePath + "@" + version + "/go.mod") + if err != nil { + t.Fatal(err) + } + if _, err := entry.Write(mod); err != nil { + t.Fatal(err) + } + if err = zw.Close(); err != nil { + t.Fatal(err) + } + mustFixture(t, os.WriteFile(base+".zip", archive.Bytes(), 0600)) + mustFixture(t, os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.invalid/checksum\n\ngo 1.27.0\n\nrequire "+modulePath+" "+version+"\n"), 0600)) + run := func(cache string) ([]byte, error) { + cmd := exec.Command("go", "mod", "download", modulePath+"@"+version) + cmd.Dir = root + cmd.Env = append(os.Environ(), "GO111MODULE=on", "GOMODCACHE="+filepath.Join(root, cache), "GOPROXY=file://"+proxy, "GONOPROXY=none", "GONOSUMDB="+privatePrefix, "GOSUMDB=off", "GOTOOLCHAIN=local", "GOFLAGS=", "GOWORK=off") + return cmd.CombinedOutput() + } + if output, err := run("original-cache"); err != nil { + t.Fatalf("native fixture admission: %v: %s", err, output) + } + mustFixture(t, os.WriteFile(base+".mod", append(mod, '\n'), 0600)) + output, err := run("fresh-cache") + if err == nil || !bytes.Contains(output, []byte("checksum mismatch")) { + t.Fatalf("native module checksum guard failed: %v: %s", err, output) + } +} + +func TestGeneratorMissingPrivateMetadataNeverInvokesGit(t *testing.T) { + root := t.TempDir() + t.Chdir(root) + mustFixture(t, os.WriteFile("go.mod", []byte("module example.invalid/offline\n\ngo 1.27.0\n\nrequire code.fritzlab.net/agenthub/missing v0.0.1\n"), 0600)) + mustFixture(t, os.WriteFile("go.sum", nil, 0600)) + bin := filepath.Join(root, "bin") + mustFixture(t, os.Mkdir(bin, 0700)) + marker := filepath.Join(root, "git-invoked") + mustFixture(t, os.WriteFile(filepath.Join(bin, "git"), []byte("#!/bin/sh\nprintf invoked > \"$PRIVATE_GIT_PROBE\"\nexit 99\n"), 0700)) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("PRIVATE_GIT_PROBE", marker) + t.Setenv("GOMODCACHE", filepath.Join(root, "empty-cache")) + t.Setenv("GOPRIVATE", "code.fritzlab.net") + t.Setenv("GONOPROXY", "code.fritzlab.net") + t.Setenv("GOTOOLCHAIN", "auto") + err := generate() + if err == nil { + t.Fatal("missing cached module accepted") + } + var failure *exec.ExitError + if !errors.As(err, &failure) || !bytes.Contains(failure.Stderr, []byte("module lookup disabled by GOPROXY=off")) { + t.Fatalf("not a proven offline refusal: %v", err) + } + if _, err = os.Stat(marker); !os.IsNotExist(err) { + t.Fatal("Git invoked during offline generation") + } + if _, err = os.Stat(bundleRoot); !os.IsNotExist(err) { + t.Fatal("failed generation published output") + } + mustFixture(t, os.WriteFile("go.mod", []byte("module example.invalid/offline\n\ngo 1.999.0\n"), 0600)) + _, err = nativeGo("list", "-m", "-json", "all") + if !errors.As(err, &failure) || !bytes.Contains(failure.Stderr, []byte("GOTOOLCHAIN=local")) { + t.Fatalf("toolchain download not disabled: %v", err) + } +} + +func TestCanonicalModuleArtifactPathBinding(t *testing.T) { + for _, test := range []struct{ path, version, want string }{ + {"code.fritzlab.net/agenthub/Provider", "v1.2.3-RC.1", "code.fritzlab.net/agenthub/!provider/@v/v1.2.3-!r!c.1"}, + {"code.fritzlab.net/agenthub/provider", "v2.0.0+incompatible", "code.fritzlab.net/agenthub/provider/@v/v2.0.0+incompatible"}, + } { + got, err := moduleStem(test.path, test.version) + if err != nil || got != test.want { + t.Fatalf("stem %q %v", got, err) + } + } + for _, path := range []string{"code.fritzlab.net/agenthub/../provider", "code.fritzlab.net/agenthub/provider!", "other.invalid/agenthub/provider"} { + if _, err := moduleStem(path, "v1.0.0"); err == nil { + t.Fatal("invalid path accepted") + } + } +} + +func TestLazyGraphModuleCacheIdentity(t *testing.T) { + cache := t.TempDir() + path, version := "code.fritzlab.net/fixture/module", "v0.1.0" + stem, err := cacheStem(cache, path, version, "") + if err != nil || stem != path+"/@v/"+version { + t.Fatalf("selected lazy module rejected: %q %v", stem, err) + } + for _, metadata := range []string{filepath.Join(cache, "foreign/@v/v0.1.0.mod"), filepath.Join(cache, "../escaped.mod"), filepath.Join(cache, path, "@v/v0.2.0.mod")} { + if _, err := cacheStem(cache, path, version, metadata); err == nil { + t.Fatal("contradictory cache identity accepted") + } + } +} + +func mustFixture(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatal(err) + } +}