//go:build linux package host import ( "bytes" "context" "crypto/ed25519" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" "flag" "os" "path/filepath" "strings" "sync" "sync/atomic" "testing" "github.com/carlosframework/platform/internal/claimactivation" "github.com/carlosframework/platform/internal/hostactors" "github.com/carlosframework/platform/internal/hostfloor" "github.com/carlosframework/platform/internal/publicrelease" "github.com/carlosframework/platform/internal/release" ) func artifactSHA(b []byte) string { s := sha256.Sum256(b); return hex.EncodeToString(s[:]) } // artifactWorld is a fresh deployment ready for sign-policy and // publish-identity: roster initialized and configured, the console shipped // and promoted with pointer digests on. type artifactWorld struct { r claimactivation.Runner store release.Store pub ed25519.PublicKey router []byte sums string sig string } func newArtifactWorld(t *testing.T) *artifactWorld { t.Helper() ctx := t.Context() store := release.NewDirStore(t.TempDir()) pub, key, _ := ed25519.GenerateKey(rand.Reader) sumsPub, sumsKey, _ := ed25519.GenerateKey(rand.Reader) w := &artifactWorld{store: store, pub: pub, router: []byte("carlos v0.28.2"), r: claimactivation.Runner{Directory: hostactors.Directory{Store: store, PublicKey: pub, Bucket: "bucket"}, Signer: release.RootSigner(key), SumsKey: base64.StdEncoding.EncodeToString(sumsPub), ConsoleBuild: func([]byte) string { return "v0.28.2" }}} w.run(t, claimactivation.Plan{Phase: "initialize"}) w.run(t, claimactivation.Plan{Phase: "configure", Regions: map[string]hostactors.Region{"ie": {Account: "123456789012", AWSRegion: "eu-west-1", Namespaces: []string{"platform"}, Pools: []string{"console"}}}}) if err := release.WriteDigestMarker(ctx, store, w.r.Signer, "bab", "test"); err != nil { t.Fatal(err) } m := release.Manifest{App: "console", Version: "abc1234", Kind: "binary", Artifacts: map[string]release.Artifact{"linux-arm64": {}}} if err := release.Ship(ctx, store, "bab", m, map[string][]byte{"linux-arm64": []byte("console build")}); err != nil { t.Fatal(err) } if err := w.r.Signer.Promote(ctx, store, "bab", "console", "canary/console", "abc1234"); err != nil { t.Fatal(err) } w.sums = artifactSHA(w.router) + " carlos-linux-arm64\n" + artifactSHA([]byte("amd64 deb")) + " carlos_0.28.2_amd64.deb\n" + artifactSHA([]byte("arm64 deb")) + " carlos_0.28.2_arm64.deb\n" w.sig = base64.StdEncoding.EncodeToString(ed25519.Sign(sumsKey, []byte(w.sums))) return w } // plan fills in the bucket and the current roster revision. func (w *artifactWorld) plan(t *testing.T, p claimactivation.Plan) claimactivation.Plan { t.Helper() p.Bucket = "bucket" if o, err := w.r.Directory.Read(t.Context()); err == nil { p.ExpectedRevision = o.Roster.Revision } return p } func (w *artifactWorld) run(t *testing.T, p claimactivation.Plan) claimactivation.Result { t.Helper() result, err := w.r.Run(t.Context(), w.plan(t, p), false) if err != nil { t.Fatal(p.Phase, err) } return result } func (w *artifactWorld) policyPlan(t *testing.T) claimactivation.Plan { floor := hostfloor.Floor{Min: 1, MinRelease: "v0.28.2"} return w.plan(t, claimactivation.Plan{Phase: "sign-policy", Floor: &floor}) } func (w *artifactWorld) identityPlan(t *testing.T) claimactivation.Plan { return w.plan(t, claimactivation.Plan{Phase: "publish-identity", Identity: &claimactivation.IdentityInput{ReleaseTag: "v0.28.2", MovesProtocol: 1, Sums: w.sums, SumsSignature: w.sig, Console: claimactivation.ConsoleRelease{Account: "bab", App: "console", Channel: "canary/console", Version: "abc1234"}}}) } // artifactPhases runs sign-policy and publish-identity on a fresh deployment // and returns their outputs, the deployment key and the router build's hash. func artifactPhases(t *testing.T) (policy, identity []byte, pub ed25519.PublicKey, router []byte) { t.Helper() w := newArtifactWorld(t) policy = w.run(t, w.policyPlan(t)).Output identity = w.run(t, w.identityPlan(t)).Output return policy, identity, w.pub, w.router } // writeOut reserves name under dir and writes raw to it, as claims run // --out does. func writeOut(dir *os.File, name string, raw []byte, dry bool) error { out, err := reserveActivationOutputAt(dir, name, dry) if err != nil { return err } err = out.finish(raw) out.close() return err } func openDir(t *testing.T) (*os.File, string) { t.Helper() path := t.TempDir() dir, err := os.Open(path) if err != nil { t.Fatal(err) } t.Cleanup(func() { dir.Close() }) return dir, path } // C4: the phases' files are written byte for byte, 0600 and exclusively, // and guard-arm's own parse accepts exactly those bytes. A re-indented // policy (what jq would print) is refused, which is why nothing may pass // through jq. func TestActivationOutputRoundTripsIntoGuardArm(t *testing.T) { policy, identity, pub, router := artifactPhases(t) dir, dirPath := openDir(t) var err error for name, raw := range map[string][]byte{"policy.json": policy, "identity.json": identity} { if err = writeOut(dir, name, raw, true); err != nil { t.Fatal("dry run", name, err) } if _, err = os.Lstat(filepath.Join(dirPath, name)); !os.IsNotExist(err) { t.Fatal("dry run wrote", name, err) } if err = writeOut(dir, name, raw, false); err != nil { t.Fatal(name, err) } st, err := os.Lstat(filepath.Join(dirPath, name)) if err != nil || st.Mode() != 0o600 { t.Fatal(name, st, err) } if err = writeOut(dir, name, raw, false); err == nil { t.Fatal("replaced an existing", name) } if err = writeOut(dir, name, raw, true); err == nil { t.Fatal("dry run accepted an existing", name) } } policyFile, _ := os.ReadFile(filepath.Join(dirPath, "policy.json")) identityFile, _ := os.ReadFile(filepath.Join(dirPath, "identity.json")) if !bytes.Equal(policyFile, policy) || !bytes.Equal(identityFile, identity) { t.Fatal("files are not the phase output") } p, f, err := guardArmInputs(policyFile, identityFile, pub, "bucket", "v0.28.2", "linux-arm64", artifactSHA(router)) if err != nil || p.Mode != "armed" || f.Floor.MinRelease != "v0.28.2" { t.Fatal("guard-arm refused the written files", err) } var indented bytes.Buffer if err = json.Indent(&indented, policyFile, "", " "); err != nil { t.Fatal(err) } if _, _, err = guardArmInputs(indented.Bytes(), identityFile, pub, "bucket", "v0.28.2", "linux-arm64", artifactSHA(router)); err == nil { t.Fatal("guard-arm accepted a re-indented policy") } for name, args := range map[string][]string{ "other build": {"v0.28.1", "linux-arm64", artifactSHA(router)}, "other platform": {"v0.28.2", "linux-amd64", artifactSHA(router)}, "other image": {"v0.28.2", "linux-arm64", strings.Repeat("0", 64)}, } { if _, _, err = guardArmInputs(policyFile, identityFile, pub, "bucket", args[0], args[1], args[2]); err == nil { t.Fatal("guard-arm accepted", name) } } for _, name := range []string{"", ".", "..", "a/b"} { if err = writeOut(dir, name, policy, false); err == nil { t.Fatalf("wrote %q", name) } } } func TestConsoleInstallTargetNeedsThePublishedIdentity(t *testing.T) { _, identity, pub, _ := artifactPhases(t) tag, sha, err := consoleInstallTarget(identity, identity, pub, "v0.28.2", "linux-arm64") if err != nil || tag != "v0.28.2" || sha != artifactSHA([]byte("console build")) { t.Fatal(tag, sha, err) } other, _, _ := ed25519.GenerateKey(rand.Reader) for name, tc := range map[string]struct { staged, published []byte pub ed25519.PublicKey build, platform string }{ "not published": {identity, nil, pub, "v0.28.2", "linux-arm64"}, "other bytes": {identity, append(append([]byte(nil), identity...), '\n'), pub, "v0.28.2", "linux-arm64"}, "other key": {identity, identity, other, "v0.28.2", "linux-arm64"}, "other build": {identity, identity, pub, "v0.28.1", "linux-arm64"}, "other platform": {identity, identity, pub, "v0.28.2", "linux-amd64"}, "not identity": {[]byte("{}"), []byte("{}"), pub, "v0.28.2", "linux-arm64"}, } { if _, _, err := consoleInstallTarget(tc.staged, tc.published, tc.pub, tc.build, tc.platform); err == nil { t.Fatal("accepted", name) } } } func TestInstallConsoleRefusesWithoutRoot(t *testing.T) { if os.Geteuid() == 0 { t.Skip("runs as non-root") } src := filepath.Join(t.TempDir(), "console") if err := os.WriteFile(src, []byte("console build"), 0o755); err != nil { t.Fatal(err) } dir := filepath.Join(t.TempDir(), "protected") for _, dry := range []bool{true, false} { if _, err := installProtectedConsole(dir, src, artifactSHA([]byte("console build")), dry); err == nil { t.Fatal("installed without root") } } if _, err := os.Lstat(dir); !os.IsNotExist(err) { t.Fatal("refusal created the directory", err) } } func TestInstallConsoleUpgradeRefusesWithoutRoot(t *testing.T) { if os.Geteuid() == 0 { t.Skip("runs as non-root") } body := []byte("console build") src := filepath.Join(t.TempDir(), "console") if err := os.WriteFile(src, body, 0o755); err != nil { t.Fatal(err) } dir := filepath.Join(t.TempDir(), "protected") for _, dry := range []bool{true, false} { if _, err := upgradeProtectedConsole(dir, src, artifactSHA(body), artifactSHA([]byte("old")), dry); err == nil { t.Fatal("upgraded without root") } } if _, err := os.Lstat(dir); !os.IsNotExist(err) { t.Fatal("refusal created the directory", err) } } // consoleDigest refuses more than consoleMaxBytes, by size or by bytes read // (a pipe reports size 0), and an upgrade refuses a source path inside the // console directory before reading anything. func TestConsoleDigestRefusesOversized(t *testing.T) { t.Parallel() big := filepath.Join(t.TempDir(), "big") if err := os.WriteFile(big, nil, 0o755); err != nil { t.Fatal(err) } if err := os.Truncate(big, consoleMaxBytes+2); err != nil { t.Fatal(err) } f, err := os.Open(big) if err != nil { t.Fatal(err) } defer f.Close() if _, err := consoleDigest(f); err == nil { t.Fatal("hashed an oversized file") } r, w, err := os.Pipe() if err != nil { t.Fatal(err) } defer r.Close() go func() { buf := make([]byte, 1<<20) for n := int64(0); n <= consoleMaxBytes; n += int64(len(buf)) { if _, err := w.Write(buf); err != nil { break } } w.Close() }() if _, err := consoleDigest(r); err == nil { t.Fatal("hashed an oversized stream") } small := filepath.Join(t.TempDir(), "small") if err := os.WriteFile(small, []byte("console build"), 0o755); err != nil { t.Fatal(err) } s, err := os.Open(small) if err != nil { t.Fatal(err) } defer s.Close() if got, err := consoleDigest(s); err != nil || got != artifactSHA([]byte("console build")) { t.Fatal(got, err) } } // --upgrade and --from are install-console's, and come together; anywhere // else they are refused before any input is read, as unknown flags were. func TestClaimsUpgradeFlagsBelongToInstallConsole(t *testing.T) { sha := artifactSHA([]byte("old")) for _, args := range [][]string{ {"install-console", "--upgrade"}, {"install-console", "--from", sha}, {"install-console", "--upgrade=false", "--from", sha}, {"install-console", "--upgrade", "--from", ""}, {"install-console", "--upgrade=false"}, {"inspect", "--upgrade", "--from", sha}, {"console-report", "--from", sha}, } { var out bytes.Buffer if err := runSystemClaims(args, &out); !errors.Is(err, errActivation) || out.Len() != 0 { t.Fatal("accepted", args, err, out.String()) } } parse := func(args ...string) *flag.FlagSet { fs := flag.NewFlagSet("claims", flag.ContinueOnError) fs.Bool("upgrade", false, "") fs.String("from", "", "") fs.String("source", "", "") if err := fs.Parse(args); err != nil { t.Fatal(err) } return fs } for _, tc := range []struct { command string args []string upgrade bool from string refused bool }{ {"install-console", []string{"--source", "x"}, false, "", false}, {"inspect", nil, false, "", false}, {"install-console", []string{"--upgrade", "--from", sha}, true, sha, false}, {"install-console", []string{"--upgrade"}, true, "", true}, {"install-console", []string{"--from", sha}, false, sha, true}, {"install-console", []string{"--upgrade=false"}, false, "", true}, {"install-console", []string{"--upgrade", "--from", ""}, true, "", true}, {"inspect", []string{"--upgrade", "--from", sha}, true, sha, true}, {"install-control", []string{"--from", sha}, false, sha, true}, {"install-control", []string{"--upgrade"}, true, "", true}, } { if got := upgradeFlagsRefused(tc.command, parse(tc.args...), tc.upgrade, tc.from); got != tc.refused { t.Fatal(tc.command, tc.args, "refused", got) } } } // claims run verifies release checksum lists with the vendor key the // binary embeds, never a key from its inputs. func TestActivationRunnerUsesTheVendorSumsKey(t *testing.T) { pub, key, _ := ed25519.GenerateKey(rand.Reader) r := activationRunner(release.NewDirStore(t.TempDir()), pub, "bucket", key) if r.SumsKey != publicrelease.Default().PubKey || r.SumsKey != "R+EO3qQ47VsuKl9Qq/pkDXBucPYvzp5lU2ut4zpnJPc=" { t.Fatal("sums key", r.SumsKey) } } func TestClaimsUsageNamesTheArtifactCommands(t *testing.T) { for _, want := range []string{"install-console --identity FILE --source FILE --bucket B --aws-region R --public-key K [--upgrade --from SHA256]\n", "[--out FILE]"} { if !strings.Contains(activationClaimsUsage, want) { t.Fatal("usage lacks", want) } } } // gatedStore holds its first Put until release is closed, after telling // reached, and counts Puts. type gatedStore struct { release.Store reached, release chan struct{} once sync.Once puts atomic.Int32 } func (s *gatedStore) Put(ctx context.Context, key string, raw []byte, etag string) (string, error) { s.puts.Add(1) s.once.Do(func() { close(s.reached); <-s.release }) return s.Store.Put(ctx, key, raw, etag) } // F4: two invocations naming the same --out. The first has reserved the // path and is inside its identity PUT; the second fails its reservation // before its phase runs and sends the bucket nothing. The first then // finishes and the file is its published identity. func TestActivationOutputCompetingInvocations(t *testing.T) { w := newArtifactWorld(t) dir, dirPath := openDir(t) winner := &gatedStore{Store: w.store, reached: make(chan struct{}), release: make(chan struct{})} loser := &gatedStore{Store: w.store, reached: make(chan struct{}), release: make(chan struct{})} close(loser.release) plan := w.identityPlan(t) invoke := func(store *gatedStore) (claimactivation.Result, *putAttempts, error) { attempts := &putAttempts{Store: store} out, err := reserveActivationOutputAt(dir, "identity.json", false) if err != nil { return claimactivation.Result{}, attempts, err } result, err := runProducingPhase(t.Context(), w.r, attempts, plan, out, false) return result, attempts, err } type outcome struct { result claimactivation.Result err error } done := make(chan outcome) go func() { result, _, err := invoke(winner) done <- outcome{result, err} }() <-winner.reached if _, attempts, err := invoke(loser); err == nil || attempts.attempted.Load() || loser.puts.Load() != 0 { t.Fatal("the loser published or was not refused", err, loser.puts.Load()) } close(winner.release) got := <-done if got.err != nil { t.Fatal(got.err) } file, err := os.ReadFile(filepath.Join(dirPath, "identity.json")) stored, _, serr := w.store.Get(t.Context(), "control/system/router/releases/v0.28.2.json") if err != nil || serr != nil || !bytes.Equal(file, stored) || !bytes.Equal(file, got.result.Output) { t.Fatal("winner's file is not the published identity", err, serr) } } // F5: a failure keeps the reservation, never removes it, and the error // names it and asks for a new --out path. A failure before any bucket write // says only that. Nothing is removed even when the entry was replaced: the // write refuses and the replacement stays. func TestActivationOutputFailureKeepsTheReservation(t *testing.T) { w := newArtifactWorld(t) dir, dirPath := openDir(t) path := filepath.Join(dirPath, "policy.json") stale := w.policyPlan(t) stale.ExpectedRevision-- out, err := reserveOutput(dir, "policy.json", path, false) if err != nil { t.Fatal(err) } attempts := &putAttempts{Store: w.store} _, err = runProducingPhase(t.Context(), w.r, attempts, stale, out, false) if err == nil || attempts.attempted.Load() || !strings.HasSuffix(err.Error(), "; "+path+" was kept. run it again with a new --out path") { t.Fatal("stale plan", err) } if st, err := os.Lstat(path); err != nil || st.Size() != 0 || st.Mode() != 0o600 { t.Fatal("reservation not kept", st, err) } if _, err = reserveOutput(dir, "policy.json", path, false); err == nil { t.Fatal("rerun on the kept path accepted") } // The dry run reserves nothing, so it has nothing to keep. other := filepath.Join(dirPath, "dry.json") out, err = reserveOutput(dir, "dry.json", other, true) if err != nil { t.Fatal(err) } if _, err = runProducingPhase(t.Context(), w.r, &putAttempts{Store: w.store}, stale, out, true); err == nil || strings.Contains(err.Error(), "was kept") { t.Fatal("dry run", err) } if _, err = os.Lstat(other); !os.IsNotExist(err) { t.Fatal("dry run created its output", err) } // A replaced entry: the write refuses and nothing is removed. replaced := filepath.Join(dirPath, "identity.json") out, err = reserveOutput(dir, "identity.json", replaced, false) if err != nil { t.Fatal(err) } if err = os.Rename(replaced, replaced+".moved"); err != nil { t.Fatal(err) } if err = os.WriteFile(replaced, []byte("someone else's"), 0o600); err != nil { t.Fatal(err) } _, err = runProducingPhase(t.Context(), w.r, &putAttempts{Store: w.store}, w.policyPlan(t), out, false) if err == nil || !strings.HasSuffix(err.Error(), replaced+" was kept. run it again with a new --out path") { t.Fatal("finished into a replaced entry", err) } if b, err := os.ReadFile(replaced); err != nil || string(b) != "someone else's" { t.Fatal("the replacement changed", err) } if _, err = os.Lstat(replaced + ".moved"); err != nil { t.Fatal("the reservation was removed", err) } } // failingReadback commits Puts to key and then fails every Get of key, as // when a create reaches the bucket and its readback is lost. type failingReadback struct { release.Store key string sent atomic.Bool } func (s *failingReadback) Put(ctx context.Context, key string, raw []byte, etag string) (string, error) { tag, err := s.Store.Put(ctx, key, raw, etag) if key == s.key { s.sent.Store(true) } return tag, err } func (s *failingReadback) Get(ctx context.Context, key string) ([]byte, string, error) { if key == s.key && s.sent.Load() { return nil, "", errors.New("readback lost") } return s.Store.Get(ctx, key) } // A failure after the identity reached the bucket keeps the reserved file // and says so. func TestActivationOutputKeptAfterPublication(t *testing.T) { w := newArtifactWorld(t) dir, dirPath := openDir(t) out, err := reserveOutput(dir, "identity.json", filepath.Join(dirPath, "identity.json"), false) if err != nil { t.Fatal(err) } key := "control/system/router/releases/v0.28.2.json" _, err = runProducingPhase(t.Context(), w.r, &putAttempts{Store: &failingReadback{Store: w.store, key: key}}, w.identityPlan(t), out, false) if err == nil || !strings.Contains(err.Error(), "the phase wrote to the bucket before it failed, so "+out.path+" was kept. run it again with a new --out path") { t.Fatal("failure after publication", err) } if _, err = os.Lstat(out.path); err != nil { t.Fatal("reservation removed after publication", err) } if _, _, err = w.store.Get(t.Context(), key); err != nil { t.Fatal("identity not published", err) } }