Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions free/ci/cmd/artifacts_signer.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,13 @@ func countTrue(b ...bool) (n int) {
return n
}

// signerCLIEnv is added to every cli child of the signer: the funnel allowlists the
// environment, and the released cli (1.4.x) has `config secrets resolve` only with NSELF_V15=1 (D-1236).
var signerCLIEnv = []string{"NSELF_V15=1"}

// signerResolver resolves release secrets through the exec funnel and the cli.
func signerResolver(root string) secrets.ValueResolver {
return &secrets.Resolver{Runner: &secrets.ExecRunner{Home: os.Getenv("NSELF_CI_HOME"), CoordinatorID: "signer", Attempt: "key"}, Project: root}
return &secrets.Resolver{Runner: &secrets.ExecRunner{Home: os.Getenv("NSELF_CI_HOME"), CoordinatorID: "signer", Attempt: "key", Env: signerCLIEnv}, Project: root}
}

// signerStore writes the key with `nself config secrets set` over stdin.
Expand All @@ -120,7 +124,7 @@ func signerStore(root string) (signing.SecretStore, error) {
if err != nil {
return nil, err
}
return &signing.CLIStore{Runner: &signing.FunnelRunner{Home: os.Getenv("NSELF_CI_HOME")}, Project: root, LockDir: filepath.Join(home, ".nself", "locks")}, nil
return &signing.CLIStore{Runner: &signing.FunnelRunner{Home: os.Getenv("NSELF_CI_HOME")}, Project: root, Env: signerCLIEnv, LockDir: filepath.Join(home, ".nself", "locks")}, nil
}

func signerRequest(ctx context.Context, root string) int {
Expand Down
86 changes: 86 additions & 0 deletions free/ci/cmd/artifacts_signer_env_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package main

// Purpose: the signer's cli children get NSELF_V15=1 (D-1236). The released cli 1.4.x has
// `config secrets resolve` only with it, and the exec funnel strips the caller's environment.
// Inputs: the fake cli of artifacts_key_test.go behind a wrapper that gates resolve like cli 1.4.
// Outputs: pass or fail.
// Constraints: unix only (shell wrapper).

import (
"context"
"os"
"os/exec"
"path/filepath"
"slices"
"testing"

"github.com/nself-org/plugins/free/ci/internal/secrets"
"github.com/nself-org/plugins/free/ci/internal/signing"
)

// gateResolveOnV15 puts a wrapper in front of the fake cli: `config secrets resolve` answers exit 2
// (unknown command) unless the child has NSELF_V15=1, as a released cli 1.4.x does.
func gateResolveOnV15(t *testing.T) {
t.Helper()
real, err := exec.LookPath("nself")
if err != nil {
t.Fatal(err)
}
if err = os.Rename(real, real+".real"); err != nil {
t.Fatal(err)
}
script := "#!/bin/sh\nif [ \"$1 $2 $3\" = \"config secrets resolve\" ] && [ \"$NSELF_V15\" != 1 ]; then exit 2; fi\nexec '" + real + ".real' \"$@\"\n"
if err = os.WriteFile(real, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
}

func TestSignerResolverChildSeesV15(t *testing.T) {
for _, tc := range []struct {
name string
gated bool
env string // the caller's own NSELF_V15 (the funnel must not depend on it)
}{
{"cli 1.4 gates resolve, caller env stripped", true, ""},
{"cli 1.4 gates resolve, caller env set", true, "1"},
{"cli without the gate", false, ""},
} {
t.Run(tc.name, func(t *testing.T) {
dir := fakeNself(t)
if tc.gated {
gateResolveOnV15(t)
}
t.Setenv("NSELF_V15", tc.env)
t.Setenv("NSELF_CI_HOME", t.TempDir())
if err := os.WriteFile(filepath.Join(dir, "RELEASE_KEY"), []byte("k"), 0o600); err != nil {
t.Fatal(err)
}
res := signerResolver(t.TempDir())
ctx := context.Background()
if err := res.Probe(ctx); err != nil {
t.Fatalf("probe: %v", err)
}
ref, err := secrets.Parse("release/RELEASE_KEY")
if err != nil {
t.Fatal(err)
}
vals, err := res.Resolve(ctx, "key", []secrets.Ref{ref})
if err != nil || len(vals) != 1 || string(vals[0].Bytes) != "k" {
t.Fatalf("resolve: %v %v", vals, err)
}
})
}
}

// The key store the signer writes with passes the same environment to its cli children.
func TestSignerStoreChildSeesV15(t *testing.T) {
t.Setenv("HOME", t.TempDir())
st, err := signerStore(t.TempDir())
if err != nil {
t.Fatal(err)
}
cs, ok := st.(*signing.CLIStore)
if !ok || !slices.Contains(cs.Env, "NSELF_V15=1") {
t.Fatalf("store %T env %v", st, cs)
}
}
4 changes: 3 additions & 1 deletion free/ci/internal/secrets/runner_exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ type ExecRunner struct {
CoordinatorID string
// Attempt is the attempt the calls serve; it prefixes the funnel's own call ids.
Attempt string
// Env is added to the environment of every call (a call's own Env follows it).
Env []string

seq atomic.Uint64
}
Expand Down Expand Up @@ -83,7 +85,7 @@ func (x *ExecRunner) Run(ctx context.Context, c Call) (Output, error) {
if coord == "" {
coord = "secrets"
}
ex := &ciexec.Executor{Isolation: ciexec.BaselineIsolator{}, Env: callEnv(c.Env), NewRedactor: func() ciexec.Redactor { return passRedactor{} }}
ex := &ciexec.Executor{Isolation: ciexec.BaselineIsolator{}, Env: callEnv(append(append([]string(nil), x.Env...), c.Env...)), NewRedactor: func() ciexec.Redactor { return passRedactor{} }}
var cap capture
res := ex.Run(ctx, ciexec.JobSpec{AttemptID: x.id(), CoordinatorID: coord, Command: c.Argv, Home: x.Home, Timeout: c.Timeout, Output: cap.write})
out := Output{Bytes: cap.buf, ExitCode: res.ExitCode}
Expand Down
Loading