From edb4a30cf9697370a86139e46e050a639235cf52 Mon Sep 17 00:00:00 2001 From: mitchell Date: Wed, 24 Jun 2026 12:16:36 -0400 Subject: [PATCH 1/2] Add --build to state publish: build, encrypt, wrap, upload Add a --build flag to state publish that, on the customer's own machine, packs the source tree into a wheel, encrypts it under the organization key, wraps the ciphertext together with a cleartext runtime.json into a tar.gz, and uploads only that wrapped artifact. Publishing without --build is unchanged. The org key is fetched and validated before anything is built, so a private publish fails closed (nothing is uploaded) when no key service is configured or the key is unavailable. The plaintext wheel lives only in a temp dir that is removed on every path. --build requires a private/ namespace. The wheel package is split so resolution and packing are distinct: ResolveMetadata reads pyproject.toml and applies --name/--version overrides, and Pack takes the resolved metadata and builds the wheel. artifactcrypto.Encrypt now rejects an over-long header (driven by the key id length) rather than emitting a payload its own reader would reject. The genesis/timeless publish flag is left as a TODO until the platform supports it (ENG-1641); until then --build is not genesis-stamped. ENG-1634 Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/state/internal/cmdtree/publish.go | 5 + internal/artifactcrypto/artifactcrypto.go | 2 + .../artifactcrypto/artifactcrypto_test.go | 8 + internal/artifactcrypto/encrypt.go | 3 + internal/python/wheel/distinfo.go | 2 +- internal/python/wheel/metadata.go | 25 +-- internal/python/wheel/metadata_test.go | 6 +- internal/python/wheel/wheel.go | 24 +-- internal/python/wheel/wheel_test.go | 6 +- internal/runners/publish/build.go | 193 ++++++++++++++++++ internal/runners/publish/build_test.go | 170 +++++++++++++++ internal/runners/publish/publish.go | 15 +- 12 files changed, 422 insertions(+), 37 deletions(-) create mode 100644 internal/runners/publish/build.go create mode 100644 internal/runners/publish/build_test.go diff --git a/cmd/state/internal/cmdtree/publish.go b/cmd/state/internal/cmdtree/publish.go index 553ae085f8..836d0bad08 100644 --- a/cmd/state/internal/cmdtree/publish.go +++ b/cmd/state/internal/cmdtree/publish.go @@ -111,6 +111,11 @@ func newPublish(prime *primer.Values) *captain.Command { Description: locale.Tl("author_upload_metafile_description", "A yaml file expressing the ingredient meta information. Use --editor to review the file format."), Value: ¶ms.MetaFilepath, }, + { + Name: "build", + Description: locale.Tl("author_upload_build_description", "Build, encrypt, and publish a private ingredient from a local source directory. The ingredient name, version, and namespace remain visible to the platform; the source contents do not."), + Value: ¶ms.Build, + }, }, []*captain.Argument{ { diff --git a/internal/artifactcrypto/artifactcrypto.go b/internal/artifactcrypto/artifactcrypto.go index e531249ec5..dd595550c3 100644 --- a/internal/artifactcrypto/artifactcrypto.go +++ b/internal/artifactcrypto/artifactcrypto.go @@ -39,6 +39,8 @@ var ( ErrWrongKey = errs.New("key does not match payload fingerprint") // ErrInvalidKeySize indicates the supplied key is not a 32-byte AES-256 key. ErrInvalidKeySize = errs.New("key must be 32 bytes (AES-256)") + // ErrHeaderTooLarge indicates the serialized header (driven by the key id length) exceeds the readable maximum. + ErrHeaderTooLarge = errs.New("encrypted payload header exceeds the maximum size") ) const ( diff --git a/internal/artifactcrypto/artifactcrypto_test.go b/internal/artifactcrypto/artifactcrypto_test.go index b7447b7650..5c8293f06e 100644 --- a/internal/artifactcrypto/artifactcrypto_test.go +++ b/internal/artifactcrypto/artifactcrypto_test.go @@ -276,3 +276,11 @@ type failingReader struct{} func (failingReader) Read([]byte) (int, error) { return 0, errors.New("body should not have been read") } + +func TestEncryptRejectsOversizeHeader(t *testing.T) { + oversizeKeyID := string(bytes.Repeat([]byte("k"), maxHeaderLen+1)) + err := Encrypt(failingReader{}, io.Discard, testKey, oversizeKeyID) + if !errors.Is(err, ErrHeaderTooLarge) { + t.Fatalf("error = %v, want ErrHeaderTooLarge", err) + } +} diff --git a/internal/artifactcrypto/encrypt.go b/internal/artifactcrypto/encrypt.go index 99adc96170..9523a9baf9 100644 --- a/internal/artifactcrypto/encrypt.go +++ b/internal/artifactcrypto/encrypt.go @@ -20,6 +20,9 @@ func Encrypt(src io.Reader, dst io.Writer, key []byte, keyID string) error { } raw := serializeHeader(keyID, Fingerprint(key), uint32(encChunkSize)) + if len(raw) > maxHeaderLen { + return ErrHeaderTooLarge + } var lenBuf [4]byte binary.BigEndian.PutUint32(lenBuf[:], uint32(len(raw))) if _, err := dst.Write(lenBuf[:]); err != nil { diff --git a/internal/python/wheel/distinfo.go b/internal/python/wheel/distinfo.go index 84e7c08a52..14ebdcd5ba 100644 --- a/internal/python/wheel/distinfo.go +++ b/internal/python/wheel/distinfo.go @@ -18,7 +18,7 @@ type record struct { } // buildMetadata returns the dist-info METADATA contents. -func buildMetadata(meta resolvedMetadata) []byte { +func buildMetadata(meta Metadata) []byte { var b bytes.Buffer b.WriteString("Metadata-Version: 2.1\n") b.WriteString("Name: " + meta.Name + "\n") diff --git a/internal/python/wheel/metadata.go b/internal/python/wheel/metadata.go index f7fc4dbd1b..28804c383c 100644 --- a/internal/python/wheel/metadata.go +++ b/internal/python/wheel/metadata.go @@ -10,31 +10,24 @@ import ( "github.com/BurntSushi/toml" ) -// resolvedMetadata is the metadata after merging caller overrides with -// pyproject.toml, with name and version guaranteed non-empty. -type resolvedMetadata struct { - Name string - Version string - Summary string -} - -// resolveMetadata fills empty fields of override from srcDir's pyproject.toml and -// returns the result, erroring if name or version is set by neither source. -func resolveMetadata(srcDir string, override Metadata) (resolvedMetadata, error) { +// ResolveMetadata reads srcDir's pyproject.toml [project] table and applies the +// non-empty fields of override on top, producing the metadata to pack with. It +// errors when neither source supplies a name or version. +func ResolveMetadata(srcDir string, override Metadata) (*Metadata, error) { proj, err := readPyProject(filepath.Join(srcDir, "pyproject.toml")) if err != nil { - return resolvedMetadata{}, err + return nil, err } - res := resolvedMetadata{ + meta := Metadata{ Name: firstNonEmpty(override.Name, proj.Name), Version: firstNonEmpty(override.Version, proj.Version), Summary: firstNonEmpty(override.Summary, proj.Description), } - if res.Name == "" || res.Version == "" { - return resolvedMetadata{}, ErrMissingMetadata + if meta.Name == "" || meta.Version == "" { + return nil, ErrMissingMetadata } - return res, nil + return &meta, nil } type pyProject struct { diff --git a/internal/python/wheel/metadata_test.go b/internal/python/wheel/metadata_test.go index e7a40d0e67..948f4c682c 100644 --- a/internal/python/wheel/metadata_test.go +++ b/internal/python/wheel/metadata_test.go @@ -18,7 +18,7 @@ func TestResolveMetadata(t *testing.T) { t.Run("pyproject fills empty fields", func(t *testing.T) { dir := t.TempDir() writePyproject(t, dir, "[project]\nname = \"proj\"\nversion = \"3.1\"\ndescription = \"from toml\"\n") - res, err := resolveMetadata(dir, Metadata{}) + res, err := ResolveMetadata(dir, Metadata{}) if err != nil { t.Fatal(err) } @@ -30,7 +30,7 @@ func TestResolveMetadata(t *testing.T) { t.Run("caller overrides pyproject", func(t *testing.T) { dir := t.TempDir() writePyproject(t, dir, "[project]\nname = \"proj\"\nversion = \"3.1\"\n") - res, err := resolveMetadata(dir, Metadata{Name: "override", Version: "9.9"}) + res, err := ResolveMetadata(dir, Metadata{Name: "override", Version: "9.9"}) if err != nil { t.Fatal(err) } @@ -41,7 +41,7 @@ func TestResolveMetadata(t *testing.T) { t.Run("missing name and version errors", func(t *testing.T) { dir := t.TempDir() // no pyproject.toml - if _, err := resolveMetadata(dir, Metadata{Name: "only-name"}); !errors.Is(err, ErrMissingMetadata) { + if _, err := ResolveMetadata(dir, Metadata{Name: "only-name"}); !errors.Is(err, ErrMissingMetadata) { t.Errorf("error = %v, want ErrMissingMetadata", err) } }) diff --git a/internal/python/wheel/wheel.go b/internal/python/wheel/wheel.go index f7288182b1..221cde62ff 100644 --- a/internal/python/wheel/wheel.go +++ b/internal/python/wheel/wheel.go @@ -49,22 +49,20 @@ type sourceFile struct { abs string } -// Pack builds a pure-Python wheel from srcDir, writes it into outDir as +// Pack builds a pure-Python wheel from the already-resolved meta (use +// ResolveMetadata to derive it from pyproject.toml), writes it into outDir as // {normalized_name}-{version}-py3-none-any.whl, and returns the wheel path. // // The wheel root mirrors srcDir: the caller points srcDir at the directory whose -// children are the importable packages. The top-level pyproject.toml (read for -// metadata), __pycache__ directories, *.pyc/*.pyo files, and version-control -// directories are not packed. Compiled files (.so/.pyd/.dylib) are rejected. -// Values in meta override those read from pyproject.toml; the name and version -// must resolve from one source or the other. +// children are the importable packages. The top-level pyproject.toml, __pycache__ +// directories, *.pyc/*.pyo files, and version-control directories are not packed. +// Compiled files (.so/.pyd/.dll/.dylib) are rejected. // // Output is byte-reproducible: identical input trees produce identical wheels // regardless of file timestamps. On any failure no wheel is left at the path. -func Pack(srcDir string, meta Metadata, outDir string) (_ string, rerr error) { - resolved, err := resolveMetadata(srcDir, meta) - if err != nil { - return "", errs.Wrap(err, "could not resolve package metadata") +func Pack(srcDir string, meta Metadata, outDir string) (string, error) { + if meta.Name == "" || meta.Version == "" { + return "", ErrMissingMetadata } files, err := collectFiles(srcDir) @@ -72,8 +70,8 @@ func Pack(srcDir string, meta Metadata, outDir string) (_ string, rerr error) { return "", errs.Wrap(err, "could not scan source tree") } - outPath := filepath.Join(outDir, wheelFilename(resolved.Name, resolved.Version)) - if err := writeWheel(files, resolved, outPath); err != nil { + outPath := filepath.Join(outDir, wheelFilename(meta.Name, meta.Version)) + if err := writeWheel(files, meta, outPath); err != nil { return "", errs.Wrap(err, "could not write wheel") } return outPath, nil @@ -157,7 +155,7 @@ func isNativeFile(rel string) bool { // writeWheel writes the wheel to a sibling temp file and renames it onto outPath // only after the whole archive is written, so a failure leaves outPath untouched. -func writeWheel(files []sourceFile, meta resolvedMetadata, outPath string) (rerr error) { +func writeWheel(files []sourceFile, meta Metadata, outPath string) (rerr error) { tmp, err := os.CreateTemp(filepath.Dir(outPath), filepath.Base(outPath)+".tmp-*") if err != nil { return errs.Wrap(err, "could not create temp wheel") diff --git a/internal/python/wheel/wheel_test.go b/internal/python/wheel/wheel_test.go index af2eacc3b4..391d4cccb7 100644 --- a/internal/python/wheel/wheel_test.go +++ b/internal/python/wheel/wheel_test.go @@ -93,10 +93,10 @@ func TestPackProducesValidWheel(t *testing.T) { } // METADATA / WHEEL contents. - meta := string(entries[di+"/METADATA"]) + metaFile := string(entries[di+"/METADATA"]) for _, want := range []string{"Metadata-Version: 2.1", "Name: My.Pkg-Name", "Version: 1.0", "Summary: a pkg"} { - if !bytes.Contains([]byte(meta), []byte(want)) { - t.Errorf("METADATA missing %q; got:\n%s", want, meta) + if !bytes.Contains([]byte(metaFile), []byte(want)) { + t.Errorf("METADATA missing %q; got:\n%s", want, metaFile) } } wheelFile := string(entries[di+"/WHEEL"]) diff --git a/internal/runners/publish/build.go b/internal/runners/publish/build.go new file mode 100644 index 0000000000..004f29e88f --- /dev/null +++ b/internal/runners/publish/build.go @@ -0,0 +1,193 @@ +package publish + +import ( + "context" + "encoding/json" + "os" + "path" + "path/filepath" + "strings" + + "github.com/ActiveState/cli/internal/archiver" + "github.com/ActiveState/cli/internal/artifactcrypto" + "github.com/ActiveState/cli/internal/errs" + "github.com/ActiveState/cli/internal/fileutils" + "github.com/ActiveState/cli/internal/locale" + "github.com/ActiveState/cli/internal/logging" + "github.com/ActiveState/cli/internal/python/wheel" + "github.com/ActiveState/cli/internal/runbits/orgkey" +) + +// generateEncryptedArtifact validates the --build inputs, fetches and validates +// the org key, builds the wrapped, encrypted artifact, and points the publish +// flow at it. It fails closed before producing any artifact if the key is +// unavailable, and returns a cleanup function the caller must defer. +func (r *Runner) generateEncryptedArtifact(params *Params) (cleanup func(), rerr error) { + if params.Filepath != "" { + return nil, locale.NewInputError("err_publish_build_and_file", "The '[ACTIONABLE]--build[/RESET]' flag cannot be combined with a source archive filepath.") + } + if r.project == nil { + return nil, locale.NewInputError("err_publish_build_no_project", "The '[ACTIONABLE]--build[/RESET]' flag requires a project so the organization can be determined.") + } + if params.Namespace != "" && !strings.HasPrefix(params.Namespace, "private/") { + return nil, locale.NewInputError("err_publish_build_namespace", "The '[ACTIONABLE]--build[/RESET]' flag requires a '[ACTIONABLE]private/[/RESET]' namespace.") + } + + meta, err := wheel.ResolveMetadata(params.Build, wheel.Metadata{Name: params.Name, Version: params.Version}) + if err != nil { + return nil, locale.WrapInputError(err, "err_publish_build_metadata", "Could not determine the ingredient name and version: {{.V0}}", errs.JoinMessage(err)) + } + + // Fetch and validate the org key before building anything: a private publish + // is encrypted-required, so fail closed before any byte could be uploaded. + provider := orgkey.New(r.cfg, r.project.Owner()) + if !provider.Configured() { + return nil, locale.NewInputError("err_publish_orgkey_unconfigured", "No organization key service is configured, so this private ingredient cannot be encrypted.") + } + defer provider.Close() + key, keyID, err := provider.Key(context.Background()) + if err != nil { + return nil, locale.WrapInputError(err, "err_publish_orgkey_unavailable", "Could not obtain the organization key, so nothing was uploaded: {{.V0}}", errs.JoinMessage(err)) + } + + archivePath, cleanup, err := buildWrappedArtifact(params.Build, *meta, key, keyID) + if err != nil { + return nil, errs.Wrap(err, "Could not build encrypted artifact") + } + + // TODO(ENG-1641): once the platform supports the genesis/timeless publish + // flag, set it on the publish mutation so a private publish never advances + // any commit's at_time. Until then --build cannot be genesis-stamped. + + params.Filepath = archivePath + if params.Name == "" { + params.Name = meta.Name + } + if params.Version == "" { + params.Version = meta.Version + } + return cleanup, nil +} + +// payloadInstallDir is the directory inside the wrapped artifact that holds the +// deployable payload; the cleartext runtime.json points the consume side at it. +const payloadInstallDir = "install" + +// buildWrappedArtifact packs srcDir into a wheel under the given metadata, +// encrypts it under the org key, and wraps the ciphertext together with a +// cleartext runtime.json into a tar.gz ready for upload. It returns the wrapped +// archive path and a cleanup function the caller must invoke once the upload is +// done. +// +// Only ciphertext plus the cleartext envdef ever reaches the wrapped archive: +// the plaintext wheel and payload are removed before the function returns, so no +// plaintext outlives the build. +func buildWrappedArtifact(srcDir string, meta wheel.Metadata, key []byte, keyID string) (archivePath string, cleanup func(), rerr error) { + tmpDir, err := os.MkdirTemp("", "state-publish-build-") + if err != nil { + return "", nil, errs.Wrap(err, "Could not create temp dir") + } + cleanup = func() { _ = os.RemoveAll(tmpDir) } + defer func() { + if rerr != nil { + cleanup() + } + }() + + wheelPath, err := wheel.Pack(srcDir, meta, tmpDir) + if err != nil { + return "", nil, locale.WrapInputError(err, "err_publish_build_pack", "Could not build a wheel from '{{.V0}}': {{.V1}}", srcDir, errs.JoinMessage(err)) + } + + // Assemble the tar.gz that becomes the encrypted payload, placing the wheel + // under the install dir the consume side deploys. + plaintextPayload := filepath.Join(tmpDir, "payload.tar.gz") + if err := archiver.CreateTgz(plaintextPayload, tmpDir, []archiver.FileMap{ + {Source: wheelPath, Target: path.Join(payloadInstallDir, filepath.Base(wheelPath))}, + }); err != nil { + return "", nil, errs.Wrap(err, "Could not assemble payload") + } + + ciphertextPath := filepath.Join(tmpDir, "payload.enc") + if err := encryptFile(plaintextPayload, ciphertextPath, key, keyID); err != nil { + return "", nil, errs.Wrap(err, "Could not encrypt payload") + } + + // Drop the plaintext now that only ciphertext is needed; nothing plaintext + // survives into the wrapped artifact or beyond this point. + for _, p := range []string{wheelPath, plaintextPayload} { + if err := os.Remove(p); err != nil { + return "", nil, errs.Wrap(err, "Could not remove plaintext") + } + } + + runtimeJSONPath := filepath.Join(tmpDir, "runtime.json") + if err := writeRuntimeJSON(runtimeJSONPath); err != nil { + return "", nil, errs.Wrap(err, "Could not write runtime.json") + } + + archivePath = filepath.Join(tmpDir, "ingredient.tar.gz") + if err := archiver.CreateTgz(archivePath, tmpDir, []archiver.FileMap{ + {Source: ciphertextPath, Target: "payload.enc"}, + {Source: runtimeJSONPath, Target: "runtime.json"}, + }); err != nil { + return "", nil, errs.Wrap(err, "Could not wrap artifact") + } + + if sha, err := fileutils.Sha256Hash(archivePath); err == nil { + logging.Debug("Built private ingredient artifact %s (sha256=%s)", filepath.Base(archivePath), sha) + } + + return archivePath, cleanup, nil +} + +// encryptFile streams srcPath through the content-encryption package into a new +// file at dstPath under the given key. +func encryptFile(srcPath, dstPath string, key []byte, keyID string) (rerr error) { + src, err := os.Open(srcPath) + if err != nil { + return errs.Wrap(err, "Could not open payload") + } + defer func() { + if cerr := src.Close(); cerr != nil { + rerr = errs.Pack(rerr, errs.Wrap(cerr, "Could not close payload")) + } + }() + + dst, err := os.Create(dstPath) + if err != nil { + return errs.Wrap(err, "Could not create ciphertext") + } + defer func() { + if cerr := dst.Close(); cerr != nil { + rerr = errs.Pack(rerr, errs.Wrap(cerr, "Could not close ciphertext")) + } + }() + + if err := artifactcrypto.Encrypt(src, dst, key, keyID); err != nil { + return errs.Wrap(err, "Could not encrypt") + } + return nil +} + +// writeRuntimeJSON writes the minimal cleartext envdef the consume side reads to +// deploy the decrypted payload. +func writeRuntimeJSON(destPath string) error { + def := struct { + Env []json.RawMessage `json:"env"` + Transforms []json.RawMessage `json:"file_transforms"` + InstallDir string `json:"installdir"` + }{ + Env: []json.RawMessage{}, + Transforms: []json.RawMessage{}, + InstallDir: payloadInstallDir, + } + b, err := json.Marshal(def) + if err != nil { + return errs.Wrap(err, "Could not marshal runtime.json") + } + if err := os.WriteFile(destPath, b, 0644); err != nil { + return errs.Wrap(err, "Could not write runtime.json") + } + return nil +} diff --git a/internal/runners/publish/build_test.go b/internal/runners/publish/build_test.go new file mode 100644 index 0000000000..7cc2327907 --- /dev/null +++ b/internal/runners/publish/build_test.go @@ -0,0 +1,170 @@ +package publish + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "encoding/json" + "io" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/ActiveState/cli/internal/artifactcrypto" + "github.com/ActiveState/cli/internal/python/wheel" +) + +func testKey() []byte { + return bytes.Repeat([]byte{0x42}, artifactcrypto.KeySize) +} + +func writeFile(t *testing.T, path, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0644); err != nil { + t.Fatal(err) + } +} + +// readTarGz returns the regular-file entries of a gzip'd tar keyed by name. +func readTarGz(t *testing.T, path string) map[string][]byte { + t.Helper() + f, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + gz, err := gzip.NewReader(f) + if err != nil { + t.Fatal(err) + } + tr := tar.NewReader(gz) + out := map[string][]byte{} + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + t.Fatal(err) + } + if hdr.Typeflag != tar.TypeReg { + continue + } + b, err := io.ReadAll(tr) + if err != nil { + t.Fatal(err) + } + out[filepath.ToSlash(hdr.Name)] = b + } + return out +} + +func keysOf(m map[string][]byte) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +func TestBuildWrappedArtifact(t *testing.T) { + src := t.TempDir() + writeFile(t, filepath.Join(src, "mypkg", "__init__.py"), "print('hi')\n") + writeFile(t, filepath.Join(src, "pyproject.toml"), "[project]\nname = \"My.Pkg\"\nversion = \"1.2.3\"\n") + + meta, err := wheel.ResolveMetadata(src, wheel.Metadata{}) + if err != nil { + t.Fatal(err) + } + if meta.Name != "My.Pkg" || meta.Version != "1.2.3" { + t.Errorf("resolved metadata = %+v, want name My.Pkg version 1.2.3", meta) + } + key := testKey() + + archivePath, cleanup, err := buildWrappedArtifact(src, *meta, key, "kid") + if err != nil { + t.Fatalf("buildWrappedArtifact: %v", err) + } + defer cleanup() + + // The wrapped archive contains exactly the ciphertext and the cleartext envdef. + entries := readTarGz(t, archivePath) + if got, want := keysOf(entries), []string{"payload.enc", "runtime.json"}; !equalStrings(got, want) { + t.Fatalf("wrapped entries = %v, want %v", got, want) + } + + // runtime.json is the minimal envdef pointing at the install dir. + var def struct { + InstallDir string `json:"installdir"` + } + if err := json.Unmarshal(entries["runtime.json"], &def); err != nil { + t.Fatal(err) + } + if def.InstallDir != payloadInstallDir { + t.Errorf("installdir = %q, want %q", def.InstallDir, payloadInstallDir) + } + + // The payload is encrypted before it is wrapped. + enc, err := artifactcrypto.IsEncrypted(bytes.NewReader(entries["payload.enc"])) + if err != nil { + t.Fatal(err) + } + if !enc { + t.Error("payload.enc is not an encrypted payload") + } + + // No plaintext survives in the build temp dir. + for _, e := range mustReadDir(t, filepath.Dir(archivePath)) { + if strings.HasSuffix(e.Name(), ".whl") || e.Name() == "payload.tar.gz" { + t.Errorf("plaintext leftover in temp dir: %s", e.Name()) + } + } + + // The ciphertext decrypts to a tar.gz holding the wheel under the install dir. + innerPath := filepath.Join(t.TempDir(), "inner.tar.gz") + if err := artifactcrypto.Decrypt(bytes.NewReader(entries["payload.enc"]), innerPath, key); err != nil { + t.Fatalf("Decrypt: %v", err) + } + foundWheel := false + for _, name := range keysOf(readTarGz(t, innerPath)) { + if strings.HasPrefix(name, payloadInstallDir+"/") && strings.HasSuffix(name, ".whl") { + foundWheel = true + } + } + if !foundWheel { + t.Error("decrypted payload does not contain the wheel under the install dir") + } + + // cleanup removes the build temp dir. + cleanup() + if _, err := os.Stat(filepath.Dir(archivePath)); !os.IsNotExist(err) { + t.Error("cleanup did not remove the build temp dir") + } +} + +func mustReadDir(t *testing.T, dir string) []os.DirEntry { + t.Helper() + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + return entries +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/runners/publish/publish.go b/internal/runners/publish/publish.go index 9407ad4db2..5c804fea41 100644 --- a/internal/runners/publish/publish.go +++ b/internal/runners/publish/publish.go @@ -9,6 +9,7 @@ import ( "time" "github.com/ActiveState/cli/internal/captain" + "github.com/ActiveState/cli/internal/config" "github.com/ActiveState/cli/internal/errs" "github.com/ActiveState/cli/internal/fileutils" "github.com/ActiveState/cli/internal/locale" @@ -43,6 +44,7 @@ type Params struct { Features captain.PackagesValue Filepath string MetaFilepath string + Build string Edit bool Editor bool } @@ -52,6 +54,7 @@ type Runner struct { out output.Outputer prompt prompt.Prompter project *project.Project + cfg *config.Instance bp *buildplanner.BuildPlanner } @@ -60,6 +63,7 @@ type primeable interface { primer.Auther primer.Projecter primer.Prompter + primer.Configurer primer.SvcModeler } @@ -69,6 +73,7 @@ func New(prime primeable) *Runner { out: prime.Output(), prompt: prime.Prompt(), project: prime.Project(), + cfg: prime.Config(), bp: buildplanner.NewBuildPlannerModel(prime.Auth(), prime.SvcModel()), } } @@ -87,6 +92,14 @@ func (r *Runner) Run(params *Params) error { return locale.NewInputError("err_auth_required") } + if params.Build != "" { + cleanup, err := r.generateEncryptedArtifact(params) // note: this function also mutates params + if err != nil { + return errs.Wrap(err, "Could not build private ingredient") + } + defer cleanup() // remove artifact.tar.gz and temporary build dir + } + if params.Filepath != "" { if !fileutils.FileExists(params.Filepath) { return locale.NewInputError("err_uploadingredient_file_not_found", "File not found: {{.V0}}", params.Filepath) @@ -94,7 +107,7 @@ func (r *Runner) Run(params *Params) error { if !strings.HasSuffix(strings.ToLower(params.Filepath), ".zip") && !strings.HasSuffix(strings.ToLower(params.Filepath), ".tar.gz") && !strings.HasSuffix(strings.ToLower(params.Filepath), ".whl") { - return locale.NewInputError("err_uploadingredient_file_not_supported", "Expected file extension: .zip, .tar.gz or .whl: '{{.V0}}'", params.Filepath) + return locale.NewInputError("err_uploadingredient_file_not_supported", "Expected file extension: .zip, .tar.gz or .whl: '{{.V0}}'", params.Filepath) } } else if !params.Edit { return locale.NewInputError("err_uploadingredient_file_required", "You have to supply the source archive unless editing.") From 4090ca1d0ea441461ef16e8f0f872053eac7ff36 Mon Sep 17 00:00:00 2001 From: mitchell Date: Wed, 24 Jun 2026 13:26:40 -0400 Subject: [PATCH 2/2] Enforce the project org namespace on --build publishes A --build artifact is encrypted under the project org's key, so it must be published under that same org or its consumers cannot decrypt it. Validate the resolved namespace (flag, meta file, or default) against private/ in one place, after namespace resolution, rather than only checking the flag for a private/ prefix. Also wrap the wheel.Pack error with errs.Wrap (it is not an input error), fix a stale cleanup comment, and close the gzip reader in the test helper. ENG-1634 Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/runners/publish/build.go | 18 ++++++++++++++---- internal/runners/publish/build_test.go | 17 +++++++++++++++++ internal/runners/publish/publish.go | 10 +++++++++- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/internal/runners/publish/build.go b/internal/runners/publish/build.go index 004f29e88f..8433dabf9b 100644 --- a/internal/runners/publish/build.go +++ b/internal/runners/publish/build.go @@ -16,6 +16,7 @@ import ( "github.com/ActiveState/cli/internal/logging" "github.com/ActiveState/cli/internal/python/wheel" "github.com/ActiveState/cli/internal/runbits/orgkey" + "github.com/ActiveState/cli/pkg/platform/model" ) // generateEncryptedArtifact validates the --build inputs, fetches and validates @@ -29,9 +30,6 @@ func (r *Runner) generateEncryptedArtifact(params *Params) (cleanup func(), rerr if r.project == nil { return nil, locale.NewInputError("err_publish_build_no_project", "The '[ACTIONABLE]--build[/RESET]' flag requires a project so the organization can be determined.") } - if params.Namespace != "" && !strings.HasPrefix(params.Namespace, "private/") { - return nil, locale.NewInputError("err_publish_build_namespace", "The '[ACTIONABLE]--build[/RESET]' flag requires a '[ACTIONABLE]private/[/RESET]' namespace.") - } meta, err := wheel.ResolveMetadata(params.Build, wheel.Metadata{Name: params.Name, Version: params.Version}) if err != nil { @@ -69,6 +67,18 @@ func (r *Runner) generateEncryptedArtifact(params *Params) (cleanup func(), rerr return cleanup, nil } +// requireOrgNamespace ensures ns belongs to the project owner's private org, so +// an artifact encrypted under that org's key is published under that same org +// and stays decryptable by its consumers. +func requireOrgNamespace(ns, owner string) error { + org := model.NewNamespaceOrg(owner, "").String() + if ns == org || strings.HasPrefix(ns, org+"/") { + return nil + } + return locale.NewInputError("err_publish_build_namespace", + "The '[ACTIONABLE]--build[/RESET]' flag requires a namespace under '[ACTIONABLE]{{.V0}}[/RESET]'.", org) +} + // payloadInstallDir is the directory inside the wrapped artifact that holds the // deployable payload; the cleartext runtime.json points the consume side at it. const payloadInstallDir = "install" @@ -96,7 +106,7 @@ func buildWrappedArtifact(srcDir string, meta wheel.Metadata, key []byte, keyID wheelPath, err := wheel.Pack(srcDir, meta, tmpDir) if err != nil { - return "", nil, locale.WrapInputError(err, "err_publish_build_pack", "Could not build a wheel from '{{.V0}}': {{.V1}}", srcDir, errs.JoinMessage(err)) + return "", nil, errs.Wrap(err, "Could not build a wheel from %s", srcDir) } // Assemble the tar.gz that becomes the encrypted payload, placing the wheel diff --git a/internal/runners/publish/build_test.go b/internal/runners/publish/build_test.go index 7cc2327907..292ef969ee 100644 --- a/internal/runners/publish/build_test.go +++ b/internal/runners/publish/build_test.go @@ -42,6 +42,7 @@ func readTarGz(t *testing.T, path string) map[string][]byte { if err != nil { t.Fatal(err) } + defer gz.Close() tr := tar.NewReader(gz) out := map[string][]byte{} for { @@ -73,6 +74,22 @@ func keysOf(m map[string][]byte) []string { return out } +func TestRequireOrgNamespace(t *testing.T) { + const owner = "myorg" // org namespace is private/myorg + ok := []string{"private/myorg", "private/myorg/sub", "private/myorg/a/b"} + bad := []string{"private/other", "private/myorg2", "public/myorg", "private", ""} + for _, ns := range ok { + if err := requireOrgNamespace(ns, owner); err != nil { + t.Errorf("requireOrgNamespace(%q) = %v, want nil", ns, err) + } + } + for _, ns := range bad { + if err := requireOrgNamespace(ns, owner); err == nil { + t.Errorf("requireOrgNamespace(%q) = nil, want an error", ns) + } + } +} + func TestBuildWrappedArtifact(t *testing.T) { src := t.TempDir() writeFile(t, filepath.Join(src, "mypkg", "__init__.py"), "print('hi')\n") diff --git a/internal/runners/publish/publish.go b/internal/runners/publish/publish.go index 5c804fea41..d9a00403c3 100644 --- a/internal/runners/publish/publish.go +++ b/internal/runners/publish/publish.go @@ -97,7 +97,7 @@ func (r *Runner) Run(params *Params) error { if err != nil { return errs.Wrap(err, "Could not build private ingredient") } - defer cleanup() // remove artifact.tar.gz and temporary build dir + defer cleanup() // remove the temporary build directory } if params.Filepath != "" { @@ -138,6 +138,14 @@ func (r *Runner) Run(params *Params) error { reqVars.Namespace = model.NewNamespaceOrg(r.project.Owner(), "").String() } + // A --build publish is encrypted under the project org's key, so its resolved + // namespace (flag, meta file, or default) must stay within that org. + if params.Build != "" { + if err := requireOrgNamespace(reqVars.Namespace, r.project.Owner()); err != nil { + return err + } + } + // Name if params.Name != "" { // Validate & Set name reqVars.Name = params.Name