diff --git a/README.md b/README.md index ae76e2d..ebc8599 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,11 @@ client once, then run the generated command with `-write` to copy unchanged 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 diff --git a/tests/test_private_modules_export.py b/tests/test_private_modules_export.py index dff5f4b..f9f0a1d 100644 --- a/tests/test_private_modules_export.py +++ b/tests/test_private_modules_export.py @@ -1,8 +1,11 @@ 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") @@ -11,6 +14,46 @@ 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) diff --git a/tools/private-modules/main.go b/tools/private-modules/main.go index aba1c57..5295fec 100644 --- a/tools/private-modules/main.go +++ b/tools/private-modules/main.go @@ -21,6 +21,7 @@ import ( 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"` @@ -79,6 +80,9 @@ func generate() error { return err } defer 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 { @@ -226,7 +230,11 @@ func check() error { if mod != m.GoModSHA256 || sum != m.GoSumSHA256 { return errors.New("private module closure stale; regenerate after go.mod/go.sum changes") } - expected := map[string]bool{"manifest.json": true} + 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 { diff --git a/tools/private-modules/main_test.go b/tools/private-modules/main_test.go index f16fd52..b5f14b4 100644 --- a/tools/private-modules/main_test.go +++ b/tools/private-modules/main_test.go @@ -27,9 +27,17 @@ func TestBundleRejectsStaleMissingModifiedAndUnexpectedArtifacts(t *testing.T) { } b, _ := json.Marshal(m) os.WriteFile(filepath.Join(bundleRoot, "manifest.json"), b, 0600) + 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"} { + os.WriteFile(filepath.Join(bundleRoot, "go.mod"), []byte(boundary), 0600) + if check() == nil { + t.Fatal("missing or changed archive boundary accepted") + } + } + 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" {