package main import ( "bytes" "context" "encoding/json" "errors" "flag" "fmt" "io" "log" "os" "path/filepath" "time" "github.com/carlosframework/platform/internal/historyreadback" "github.com/carlosframework/platform/internal/hostactors" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/safefile" ) const historyUsage = "usage: carlos proof history --bucket --state [--box --enrollment --account --app --host ] [--input ] [--wait ]" const historyPending = "History readback is not complete. Resume with the same --state file. Last result: %v" type historyConnect func(string, bool) (hostactors.Directory, release.Signer, error) func runHistory(args []string) { err := historyCommand(context.Background(), args, func(bucket string, resume bool) (hostactors.Directory, release.Signer, error) { if configured := os.Getenv("CARLOS_DEPLOYMENT_BUCKET"); configured != "" && configured != bucket { return hostactors.Directory{}, release.Signer{}, errors.New("--bucket must match CARLOS_DEPLOYMENT_BUCKET") } d := hostactors.Directory{Store: openStore(), PublicKey: publicKey(), Bucket: bucket} if resume { return d, release.Signer{}, nil } return d, releaseSigner(), nil }, stdout) if err != nil && !errors.Is(err, flag.ErrHelp) { log.Fatal(err) } } func historyCommand(ctx context.Context, args []string, connect historyConnect, out io.Writer) error { return historyCommandWithSave(ctx, args, connect, out, historySave) } func historyCommandWithSave(ctx context.Context, args []string, connect historyConnect, out io.Writer, save func(string, historyreadback.Operation) error) error { if len(args) == 0 { return errors.New(historyUsage) } verb := args[0] if verb != "status" && verb != "reconstruct" && verb != "retire" && verb != "resume" { return errors.New(historyUsage) } fs := flag.NewFlagSet("proof history "+verb, flag.ContinueOnError) fs.SetOutput(out) bucket := fs.String("bucket", os.Getenv("CARLOS_DEPLOYMENT_BUCKET"), "deployment bucket named in the signed box roster") box := fs.String("box", "", "box named in the signed roster") enrollment := fs.String("enrollment", "", "current box enrollment ID") account := fs.String("account", "", "account sqid") app := fs.String("app", "", "app name") host := fs.String("host", "", "canonical instance hostname") state := fs.String("state", "", "save the signed operation here before delivery; resume reuses this file") input := fs.String("input", "", "JSON reconstruction or retirement decision to sign") wait := fs.Duration("wait", 30*time.Second, "how long to wait for verified root readback") if err := fs.Parse(args[1:]); err != nil { return err } resume := verb == "resume" scope := historyreadback.Scope{Bucket: *bucket, Box: *box, Enrollment: *enrollment, Binding: release.HistoryBinding{Account: *account, App: *app, Host: *host}} if fs.NArg() != 0 || *bucket == "" || *state == "" || *wait <= 0 || (resume && (*input != "" || *box != "" || *enrollment != "" || *account != "" || *app != "" || *host != "")) || (!resume && (*box == "" || *enrollment == "" || *account == "" || *app == "" || *host == "")) || ((verb == "reconstruct" || verb == "retire") != (*input != "")) { return errors.New(historyUsage) } ctx, cancel := context.WithTimeout(ctx, *wait) defer cancel() var op historyreadback.Operation var reconstruction *release.HistoryReconstruction var retirement *release.HistoryRecoveryRetirement if resume { if err := historyReadFile(*state, &op, true); err != nil { return err } } else if verb == "reconstruct" { reconstruction = &release.HistoryReconstruction{} if err := historyReadFile(*input, reconstruction, false); err != nil { return err } } else if verb == "retire" { retirement = &release.HistoryRecoveryRetirement{} if err := historyReadFile(*input, retirement, false); err != nil { return err } } d, signer, err := connect(*bucket, resume) if err != nil { return err } if !resume { op, err = historyreadback.PrepareOperation(ctx, d, signer, scope, reconstruction, retirement, time.Now()) if err != nil { return err } if err = save(*state, op); err != nil { return err } } if err = op.Deliver(ctx, d, time.Now()); err != nil { return err } for { receipt, readErr := op.Read(ctx, d, time.Now()) if readErr == nil { enc := json.NewEncoder(out) enc.SetIndent("", " ") return enc.Encode(receipt) } timer := time.NewTimer(time.Second) select { case <-ctx.Done(): timer.Stop() return fmt.Errorf(historyPending, readErr) case <-timer.C: } } } func historyReadFile(path string, dst any, repair bool) error { f, err := safefile.OpenFile(path, os.O_RDONLY, 0) if err != nil { return err } defer f.Close() raw, err := io.ReadAll(io.LimitReader(f, 3*release.MaxRootEnvelopeBytes+1)) if err != nil { return err } if len(raw) > 3*release.MaxRootEnvelopeBytes { return os.ErrPermission } dec := json.NewDecoder(bytes.NewReader(raw)) dec.DisallowUnknownFields() if err = dec.Decode(dst); err != nil { return err } if err = dec.Decode(new(any)); err != io.EOF { return os.ErrPermission } if repair { return historySync(f, path) } return nil } func historySave(path string, op historyreadback.Operation) error { raw, err := json.Marshal(op) if err != nil { return err } f, err := safefile.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) if err != nil { return err } defer f.Close() if n, err := f.Write(raw); err != nil { return err } else if n != len(raw) { return io.ErrShortWrite } return historySync(f, path) } func historySync(f *os.File, path string) error { if err := f.Sync(); err != nil { return err } parent, err := safefile.OpenDir(filepath.Dir(path)) if err != nil { return err } defer parent.Close() current, err := safefile.OpenFile(path, os.O_RDONLY, 0) if err != nil { return err } defer current.Close() a, err := f.Stat() if err != nil { return err } b, err := current.Stat() if err != nil { return err } if !os.SameFile(a, b) { return os.ErrPermission } return parent.Sync() }