mirror of
https://github.com/BishopFox/sliver.git
synced 2026-11-07 17:08:13 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f24c057503 | ||
|
|
7e3c1f1549 | ||
|
|
1d4c0ec8a5 | ||
|
|
92dda6aa4f | ||
|
|
9a605dfda6 | ||
|
|
e918885556 | ||
|
|
263702594f | ||
|
|
72b8dfb454 | ||
|
|
aa2383ac24 | ||
|
|
b6f0ea013d | ||
|
|
eb82e18a52 | ||
|
|
40ea51b685 | ||
|
|
1285b44c50 | ||
|
|
cb63a3378f | ||
|
|
4f90bfdcda | ||
|
|
5bb1dcc137 | ||
|
|
57c417c48f | ||
|
|
55fc59f6f5 | ||
|
|
332852d095 | ||
|
|
a903d2f596 | ||
|
|
4a4bb683e3 | ||
|
|
016299c756 | ||
|
|
075caaccda | ||
|
|
729be253d3 | ||
|
|
13bd48182f | ||
|
|
67188dabea | ||
|
|
6e4c38c10e | ||
|
|
00ecb1f4fd | ||
|
|
bac047fedd | ||
|
|
10c16be351 | ||
|
|
d51ea9dbab | ||
|
|
0eb41094de | ||
|
|
a219a97cb4 | ||
|
|
b617bb4cf4 | ||
|
|
613b53430b | ||
|
|
472de54757 | ||
|
|
bdbc3bd7b5 | ||
|
|
87db61bcfb | ||
|
|
095eb2722b | ||
|
|
abbb0da434 | ||
|
|
52f7484a71 | ||
|
|
a79b5fd7b9 | ||
|
|
3e98d2d7a1 | ||
|
|
13bf9d0a01 | ||
|
|
7aae725b88 | ||
|
|
afd01073e4 | ||
|
|
ae0c33f41b | ||
|
|
18da00b449 | ||
|
|
dc736f6095 | ||
|
|
e02d53ce67 | ||
|
|
064cd6a2cc | ||
|
|
eab9d0b653 | ||
|
|
29c4ec0afa | ||
|
|
b7c336cb76 | ||
|
|
d8ddd09c86 | ||
|
|
04bb356207 | ||
|
|
a003a5e3cf | ||
|
|
75e3cad76a | ||
|
|
8aa0cbee7e | ||
|
|
30a50717e1 | ||
|
|
f4c9e7109b | ||
|
|
c09c57da3f | ||
|
|
a21b0da928 | ||
|
|
8a0ba038f6 | ||
|
|
dd072bbdb6 | ||
|
|
1e3c736acf | ||
|
|
3d880914d9 | ||
|
|
1162de9ee3 |
No files matched your search
@@ -5,6 +5,7 @@
|
||||
/artifacts
|
||||
/artifacts/**
|
||||
|
||||
*.rc
|
||||
|
||||
/Dockerfile.*
|
||||
/*.sdk.tar.xz
|
||||
|
||||
@@ -46,4 +46,3 @@ Please take a moment and fill out [our survey](https://forms.gle/SwVsHFNh24ChG58
|
||||
### License - GPLv3
|
||||
|
||||
Sliver is licensed under [GPLv3](https://www.gnu.org/licenses/gpl-3.0.en.html), some sub-components may have separate licenses. See their respective subdirectories in this project for details.
|
||||
|
||||
@@ -51,6 +51,7 @@ func init() {
|
||||
defer logFile.Close()
|
||||
|
||||
rootCmd.TraverseChildren = true
|
||||
rootCmd.Flags().String(RCFlagName, "", "path to rc script file")
|
||||
|
||||
// Create the console client, without any RPC or commands bound to it yet.
|
||||
// This created before anything so that multiple commands can make use of
|
||||
|
||||
@@ -40,6 +40,7 @@ func consoleCmd(con *console.SliverClient) *cobra.Command {
|
||||
Short: "Start the sliver client console",
|
||||
}
|
||||
|
||||
consoleCmd.Flags().String(RCFlagName, "", "path to rc script file")
|
||||
consoleCmd.RunE, consoleCmd.PersistentPostRunE = consoleRunnerCmd(con, true)
|
||||
return consoleCmd
|
||||
}
|
||||
@@ -47,7 +48,7 @@ func consoleCmd(con *console.SliverClient) *cobra.Command {
|
||||
func consoleRunnerCmd(con *console.SliverClient, run bool) (pre, post func(cmd *cobra.Command, args []string) error) {
|
||||
var ln *grpc.ClientConn
|
||||
|
||||
pre = func(_ *cobra.Command, _ []string) error {
|
||||
pre = func(cmd *cobra.Command, _ []string) error {
|
||||
|
||||
configs := assets.GetConfigs()
|
||||
if len(configs) == 0 {
|
||||
@@ -59,13 +60,18 @@ func consoleRunnerCmd(con *console.SliverClient, run bool) (pre, post func(cmd *
|
||||
return nil
|
||||
}
|
||||
|
||||
rcScript, err := ReadRCScript(cmd)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to read rc script: %s\n", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Don't clobber output when simply running an implant command from system shell.
|
||||
if run {
|
||||
fmt.Printf("Connecting to %s:%d ...\n", config.LHost, config.LPort)
|
||||
}
|
||||
|
||||
var rpc rpcpb.SliverRPCClient
|
||||
var err error
|
||||
|
||||
rpc, ln, err = transport.MTLSConnect(config)
|
||||
if err != nil {
|
||||
@@ -76,7 +82,7 @@ func consoleRunnerCmd(con *console.SliverClient, run bool) (pre, post func(cmd *
|
||||
// Wait for any connection state changes and exit if the connection is lost.
|
||||
go handleConnectionLost(ln)
|
||||
|
||||
return console.StartClient(con, rpc, command.ServerCommands(con, nil), command.SliverCommands(con), run)
|
||||
return console.StartClient(con, rpc, command.ServerCommands(con, nil), command.SliverCommands(con), run, rcScript)
|
||||
}
|
||||
|
||||
// Close the RPC connection once exiting
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const RCFlagName = "rc"
|
||||
|
||||
func ReadRCScript(cmd *cobra.Command) (string, error) {
|
||||
if cmd == nil {
|
||||
return "", nil
|
||||
}
|
||||
if cmd.Flags().Lookup(RCFlagName) == nil {
|
||||
return "", nil
|
||||
}
|
||||
rcPath, err := cmd.Flags().GetString(RCFlagName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if rcPath == "" {
|
||||
return "", nil
|
||||
}
|
||||
data, err := os.ReadFile(rcPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read rc script %q: %w", rcPath, err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# client/command/aka
|
||||
|
||||
## Overview
|
||||
|
||||
Implements the 'aka' command group for the Sliver client console. "Aka"s are
|
||||
command aliases (like 'alias' in 'bash') that allow operators to specify
|
||||
shorthand commands and default arguments for commonly used commands.
|
||||
|
||||
### Examples:
|
||||
|
||||
- `aka create bg background`: Allows an operator to run `bg` from the implant
|
||||
menu to quickly move back to the server menu
|
||||
- `aka create execw execute -o "C:\Windows\System32\cmd.exe" /c`: Shorthand for
|
||||
executing binaries on Windows targets like: `execw arp -a`
|
||||
|
||||
## Go Files
|
||||
|
||||
- `commands.go` – Wires Cobra commands for listing, creating, and deleting command aliases in the client console.
|
||||
- `create.go` – Handles creating command aliases to use by the client.
|
||||
- `delete.go` – Removes command aliases currently tracked by the client.
|
||||
- `hook.go` – The main hooking logic to intercept commands ran by the client console. The hook will intercept processing before hitting the Cobra tree to transform the command alias to a full command with default arguments from the command alias and user-provided arguments to the alias.
|
||||
- `list.go` - Lists the current command aliases tracked by the client.
|
||||
- `storage.go` - Supports the saving of aliases to disk to keep between runs of the client.
|
||||
@@ -0,0 +1,59 @@
|
||||
package aka
|
||||
|
||||
import (
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
"github.com/bishopfox/sliver/client/constants"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func ServerCommands(con *console.SliverClient) []*cobra.Command {
|
||||
return commands(con, constants.GenericHelpGroup)
|
||||
}
|
||||
|
||||
func ImplantCommands(con *console.SliverClient) []*cobra.Command {
|
||||
return commands(con, constants.SliverCoreHelpGroup)
|
||||
}
|
||||
|
||||
func commands(con *console.SliverClient, group string) []*cobra.Command {
|
||||
akaCommand := &cobra.Command{
|
||||
Use: "aka",
|
||||
Short: "Manage command aliases (aka's)",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
AkaListCmd(cmd, con, args)
|
||||
},
|
||||
GroupID: group,
|
||||
}
|
||||
|
||||
createAka := &cobra.Command{
|
||||
Use: "create <alias> <command> [args...]",
|
||||
Short: "Create a new command alias (aka)",
|
||||
Args: cobra.MinimumNArgs(2),
|
||||
DisableFlagParsing: true, // No flags with aka create so we capture flags to other commands
|
||||
DisableFlagsInUseLine: true,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
AkaCreateCmd(cmd, con, args)
|
||||
},
|
||||
}
|
||||
createAka.InitDefaultHelpFlag()
|
||||
createAka.Flags().Lookup("help").Hidden = true
|
||||
akaCommand.AddCommand(createAka)
|
||||
|
||||
deleteAka := &cobra.Command{
|
||||
Use: "delete <alias>",
|
||||
Short: "Delete a command alias (aka)",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
AkaDeleteCmd(cmd, con, args)
|
||||
},
|
||||
}
|
||||
akaCommand.AddCommand(deleteAka)
|
||||
|
||||
con.App.PreCmdRunLineHooks = append(con.App.PreCmdRunLineHooks, Cmdhook)
|
||||
|
||||
err := LoadAkaAliases()
|
||||
if err != nil {
|
||||
con.PrintErrorf("Failed to load aka alises: %s\n", err)
|
||||
}
|
||||
|
||||
return []*cobra.Command{akaCommand}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package aka
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func AkaCreateCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
|
||||
aliasName := args[0]
|
||||
command := args[1]
|
||||
defaultArgs := args[2:] // anything else is going to be the default args to the alias
|
||||
|
||||
if _, exists := akaAliases[aliasName]; exists {
|
||||
con.PrintErrorf("Alias '%s' already exists\n", aliasName)
|
||||
return
|
||||
}
|
||||
|
||||
var desc string
|
||||
if len(defaultArgs) == 0 {
|
||||
desc = fmt.Sprintf("%s", command)
|
||||
} else {
|
||||
desc = fmt.Sprintf("%s %s", command, strings.Join(defaultArgs, " "))
|
||||
}
|
||||
|
||||
alias := &AkaAlias{
|
||||
Name: aliasName,
|
||||
Command: command,
|
||||
DefaultArgs: defaultArgs,
|
||||
Description: desc,
|
||||
}
|
||||
|
||||
akaAliases[aliasName] = alias
|
||||
|
||||
// save this new alias to disk
|
||||
err := SaveAkaAliases()
|
||||
if err != nil {
|
||||
con.PrintErrorf("Failed to save alias '%s' to disk: %s\n", aliasName, err)
|
||||
// we still technically added it to the map, so the alias did get created in
|
||||
// memory so we should still print the Info message below
|
||||
}
|
||||
|
||||
con.PrintInfof("Create alias for '%s' -> '%v'\n", aliasName, alias.Description)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package aka
|
||||
|
||||
import (
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func AkaDeleteCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
|
||||
aliasName := args[0]
|
||||
if _, exists := akaAliases[aliasName]; !exists {
|
||||
con.PrintErrorf("Alias '%s' does not exist\n", aliasName)
|
||||
return
|
||||
}
|
||||
|
||||
delete(akaAliases, aliasName)
|
||||
|
||||
// Save updated map to disk
|
||||
err := SaveAkaAliases()
|
||||
if err != nil {
|
||||
con.PrintErrorf("Failed to save aliases to disk: %s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
con.PrintInfof("Deleted alias '%s'\n", aliasName)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package aka
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Cmdhook(args []string) ([]string, error) {
|
||||
if len(args) == 0 {
|
||||
return args, nil
|
||||
}
|
||||
|
||||
if aliasArgs, exists := akaAliases[args[0]]; exists {
|
||||
var expandedArgs []string
|
||||
|
||||
expandedArgs = append(expandedArgs, aliasArgs.Command)
|
||||
expandedArgs = append(expandedArgs, aliasArgs.DefaultArgs...)
|
||||
// join the user passed args so the shell processes it without flags if
|
||||
// available
|
||||
if len(args[1:]) > 0 {
|
||||
expandedArgs = append(expandedArgs, strings.Join(args[1:], " "))
|
||||
}
|
||||
|
||||
return expandedArgs, nil
|
||||
}
|
||||
|
||||
return args, nil
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package aka
|
||||
|
||||
import (
|
||||
"github.com/bishopfox/sliver/client/command/settings"
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
"github.com/jedib0t/go-pretty/v6/table"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func AkaListCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
|
||||
if len(akaAliases) == 0 {
|
||||
con.PrintInfof("No command aliases created. Use `aka create` to create one.\n")
|
||||
return
|
||||
}
|
||||
|
||||
tw := table.NewWriter()
|
||||
tw.SetStyle(settings.GetTableStyle(con))
|
||||
tw.AppendHeader(table.Row{
|
||||
"Alias",
|
||||
"Command",
|
||||
})
|
||||
|
||||
for alias, cmd := range akaAliases {
|
||||
tw.AppendRow(table.Row{
|
||||
alias,
|
||||
cmd.Description,
|
||||
})
|
||||
}
|
||||
|
||||
con.Printf("%s\n", tw.Render())
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package aka
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/bishopfox/sliver/client/assets"
|
||||
)
|
||||
|
||||
type AkaAlias struct {
|
||||
Name string `json:"name"` // alias Name
|
||||
Command string `json:"command"` // command being aliased
|
||||
DefaultArgs []string `json:"default_args"` // args passed by the user
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
var akaAliases = make(map[string]*AkaAlias)
|
||||
|
||||
const (
|
||||
akaAliasFileName = "aka-aliases.json"
|
||||
)
|
||||
|
||||
func GetAkaAliasesFilePath() string {
|
||||
return filepath.Join(assets.GetRootAppDir(), akaAliasFileName)
|
||||
}
|
||||
|
||||
func LoadAkaAliases() error {
|
||||
filepath := GetAkaAliasesFilePath()
|
||||
data, err := os.ReadFile(filepath)
|
||||
if err != nil {
|
||||
// if file hasn't been created yet, we fail gracefully
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
// otherwise, return the actual error; something else went wrong
|
||||
return err
|
||||
}
|
||||
|
||||
aliases := []AkaAlias{}
|
||||
err = json.Unmarshal(data, &aliases)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
akaAliases = make(map[string]*AkaAlias)
|
||||
|
||||
for _, alias := range aliases {
|
||||
akaAliases[alias.Name] = &alias
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func SaveAkaAliases() error {
|
||||
filepath := GetAkaAliasesFilePath()
|
||||
aliases := make([]*AkaAlias, 0, len(akaAliases))
|
||||
for _, alias := range akaAliases {
|
||||
aliases = append(aliases, alias)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(aliases, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(filepath, data, 0o600)
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import (
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
"github.com/bishopfox/sliver/protobuf/sliverpb"
|
||||
"github.com/bishopfox/sliver/util"
|
||||
"github.com/carapace-sh/carapace"
|
||||
app "github.com/reeflective/console"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
@@ -76,6 +77,16 @@ type AliasFile struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// AliasArgument - An argument for an alias command.
|
||||
type AliasArgument struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Desc string `json:"desc"`
|
||||
Optional bool `json:"optional"`
|
||||
Default interface{} `json:"default,omitempty"`
|
||||
Choices []string `json:"choices,omitempty"`
|
||||
}
|
||||
|
||||
// AliasManifest - The manifest for an alias, contains metadata.
|
||||
type AliasManifest struct {
|
||||
Name string `json:"name"`
|
||||
@@ -89,6 +100,7 @@ type AliasManifest struct {
|
||||
Entrypoint string `json:"entrypoint"`
|
||||
AllowArgs bool `json:"allow_args"`
|
||||
DefaultArgs string `json:"default_args"`
|
||||
Arguments []*AliasArgument `json:"arguments"`
|
||||
Files []*AliasFile `json:"files"`
|
||||
IsReflective bool `json:"is_reflective"`
|
||||
IsAssembly bool `json:"is_assembly"`
|
||||
@@ -158,15 +170,60 @@ func LoadAlias(manifestPath string, cmd *cobra.Command, con *console.SliverClien
|
||||
}
|
||||
aliasManifest.RootPath = filepath.Dir(manifestPath)
|
||||
|
||||
// Build usage string including arguments
|
||||
usage := strings.Builder{}
|
||||
usage.WriteString(aliasManifest.CommandName)
|
||||
for _, arg := range aliasManifest.Arguments {
|
||||
usage.WriteString(" ")
|
||||
if arg.Optional {
|
||||
usage.WriteString("[")
|
||||
}
|
||||
usage.WriteString(strings.ToUpper(arg.Name))
|
||||
if arg.Optional {
|
||||
usage.WriteString("]")
|
||||
}
|
||||
}
|
||||
|
||||
// Build long help message
|
||||
longHelp := strings.Builder{}
|
||||
longHelp.WriteString("[[.Bold]]Command:[[.Normal]]")
|
||||
longHelp.WriteString(usage.String())
|
||||
longHelp.WriteString("\n")
|
||||
if len(aliasManifest.Help) > 0 || len(aliasManifest.LongHelp) > 0 {
|
||||
longHelp.WriteString("[[.Bold]]About:[[.Normal]]")
|
||||
if len(aliasManifest.Help) > 0 {
|
||||
longHelp.WriteString(aliasManifest.Help)
|
||||
longHelp.WriteString("\n")
|
||||
}
|
||||
if len(aliasManifest.LongHelp) > 0 {
|
||||
longHelp.WriteString(aliasManifest.LongHelp)
|
||||
longHelp.WriteString("\n")
|
||||
}
|
||||
}
|
||||
if len(aliasManifest.Arguments) > 0 {
|
||||
longHelp.WriteString("[[.Bold]]Arguments:[[.Normal]]")
|
||||
for _, arg := range aliasManifest.Arguments {
|
||||
longHelp.WriteString("\n\t")
|
||||
optStr := ""
|
||||
if arg.Optional {
|
||||
optStr = "[OPTIONAL]"
|
||||
}
|
||||
aType := arg.Type
|
||||
if aType == "wstring" {
|
||||
aType = "string"
|
||||
}
|
||||
longHelp.WriteString(fmt.Sprintf("%s (%s):\t%s%s", strings.ToUpper(arg.Name), aType, optStr, arg.Desc))
|
||||
}
|
||||
}
|
||||
longHelp.WriteString("\n\n⚠️ If you're having issues passing arguments to the alias please read:\n")
|
||||
longHelp.WriteString("https://github.com/BishopFox/sliver/wiki/Aliases-&-Extensions#aliases-command-parsing")
|
||||
|
||||
// for each alias command, add a new app command
|
||||
helpMsg := fmt.Sprintf("[%s] %s", aliasManifest.Name, aliasManifest.Help)
|
||||
longHelpMsg := help.FormatHelpTmpl(aliasManifest.LongHelp)
|
||||
longHelpMsg += "\n\n⚠️ If you're having issues passing arguments to the alias please read:\n"
|
||||
longHelpMsg += "https://github.com/BishopFox/sliver/wiki/Aliases-&-Extensions#aliases-command-parsing"
|
||||
addAliasCmd := &cobra.Command{
|
||||
Use: aliasManifest.CommandName,
|
||||
Use: usage.String(),
|
||||
Short: helpMsg,
|
||||
Long: longHelpMsg,
|
||||
Long: help.FormatHelpTmpl(longHelp.String()),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runAliasCommand(cmd, con, args)
|
||||
},
|
||||
@@ -196,6 +253,10 @@ func LoadAlias(manifestPath string, cmd *cobra.Command, con *console.SliverClien
|
||||
f.IntP("timeout", "t", defaultTimeout, "command timeout in seconds")
|
||||
addAliasCmd.Flags().AddFlagSet(f)
|
||||
|
||||
// Setup completions for alias arguments
|
||||
comps := carapace.Gen(addAliasCmd)
|
||||
makeAliasArgCompleter(aliasManifest, comps)
|
||||
|
||||
cmd.AddCommand(addAliasCmd)
|
||||
|
||||
// Have to use a global map here, as passing the aliasCmd
|
||||
@@ -589,3 +650,105 @@ func makeAliasPlatformFilters(alias *AliasManifest) map[string]string {
|
||||
app.CommandFilterKey: strings.Join(all, ","),
|
||||
}
|
||||
}
|
||||
|
||||
// makeAliasArgCompleter builds the positional and dash arguments completer for the alias.
|
||||
// It provides completion for:
|
||||
// 1. Positional arguments (before --)
|
||||
// 2. Flag-style arguments after -- (e.g., --target, --port)
|
||||
func makeAliasArgCompleter(alias *AliasManifest, comps *carapace.Carapace) {
|
||||
if len(alias.Arguments) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var actions []carapace.Action
|
||||
|
||||
for _, arg := range alias.Arguments {
|
||||
var action carapace.Action
|
||||
|
||||
// If choices are defined, use them for completion
|
||||
if len(arg.Choices) > 0 {
|
||||
action = carapace.ActionValues(arg.Choices...).Tag("choices")
|
||||
} else {
|
||||
// Fall back to type-based completion
|
||||
switch arg.Type {
|
||||
case "file":
|
||||
action = carapace.ActionFiles().Tag("alias data")
|
||||
default:
|
||||
action = carapace.ActionValues()
|
||||
}
|
||||
}
|
||||
|
||||
usage := fmt.Sprintf("(%s) %s", arg.Type, arg.Desc)
|
||||
if arg.Optional {
|
||||
usage += " (optional)"
|
||||
}
|
||||
|
||||
actions = append(actions, action.Usage("%s", usage))
|
||||
}
|
||||
|
||||
comps.PositionalCompletion(actions...)
|
||||
|
||||
// Add dash completion for flag-style arguments after --
|
||||
// Pre-build the flag completions at registration time (not in a callback)
|
||||
flagCompletion := makeAliasFlagNameCompletion(alias)
|
||||
|
||||
// Build value completions for each argument type
|
||||
valueCompletions := make(map[string]carapace.Action)
|
||||
for _, arg := range alias.Arguments {
|
||||
// If choices are defined, use them for completion
|
||||
if len(arg.Choices) > 0 {
|
||||
valueCompletions[arg.Name] = carapace.ActionValues(arg.Choices...).Tag("choices")
|
||||
} else {
|
||||
// Fall back to type-based completion
|
||||
switch arg.Type {
|
||||
case "file":
|
||||
valueCompletions[arg.Name] = carapace.ActionFiles().Tag("file path")
|
||||
case "bool":
|
||||
valueCompletions[arg.Name] = carapace.ActionValues("true", "false").Tag("boolean")
|
||||
default:
|
||||
valueCompletions[arg.Name] = carapace.ActionValues()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use DashAnyCompletion with a smart action that determines context
|
||||
comps.DashAnyCompletion(
|
||||
carapace.ActionCallback(func(c carapace.Context) carapace.Action {
|
||||
// If typing a flag (starts with -)
|
||||
if strings.HasPrefix(c.Value, "-") {
|
||||
return flagCompletion
|
||||
}
|
||||
|
||||
// If previous arg was a flag, complete its value
|
||||
if len(c.Args) > 0 {
|
||||
lastArg := c.Args[len(c.Args)-1]
|
||||
if strings.HasPrefix(lastArg, "-") {
|
||||
flagName := strings.TrimLeft(lastArg, "-")
|
||||
if action, ok := valueCompletions[flagName]; ok {
|
||||
return action
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default: show flag names
|
||||
return flagCompletion
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// makeAliasFlagNameCompletion creates completion for flag names
|
||||
func makeAliasFlagNameCompletion(alias *AliasManifest) carapace.Action {
|
||||
var results []string
|
||||
|
||||
for _, arg := range alias.Arguments {
|
||||
flagName := fmt.Sprintf("--%s", arg.Name)
|
||||
desc := arg.Desc
|
||||
if arg.Optional {
|
||||
desc = fmt.Sprintf("[optional] %s", desc)
|
||||
}
|
||||
desc = fmt.Sprintf("(%s) %s", arg.Type, desc)
|
||||
results = append(results, flagName, desc)
|
||||
}
|
||||
|
||||
return carapace.ActionValuesDescribed(results...).Tag("alias arguments")
|
||||
}
|
||||
@@ -274,19 +274,22 @@ func getInstalledPackageNames() []string {
|
||||
}
|
||||
|
||||
// This is a convenience function to get the names of the commands in the cache
|
||||
func getCommandsInCache() []string {
|
||||
func getCommandsInCache(armoryPK string) []string {
|
||||
commandNames := []string{}
|
||||
|
||||
pkgCache.Range(func(key, value interface{}) bool {
|
||||
cacheEntry := value.(pkgCacheEntry)
|
||||
if cacheEntry.LastErr == nil {
|
||||
if armoryPK != "" && cacheEntry.ArmoryConfig.PublicKey != armoryPK {
|
||||
return true
|
||||
}
|
||||
if cacheEntry.Pkg.IsAlias {
|
||||
if !slices.Contains(commandNames, cacheEntry.Alias.CommandName) {
|
||||
if cacheEntry.Alias.CommandName != "" && !slices.Contains(commandNames, cacheEntry.Alias.CommandName) {
|
||||
commandNames = append(commandNames, cacheEntry.Alias.CommandName)
|
||||
}
|
||||
} else {
|
||||
for _, command := range cacheEntry.Extension.ExtCommand {
|
||||
if !slices.Contains(commandNames, command.CommandName) {
|
||||
if command.CommandName != "" && !slices.Contains(commandNames, command.CommandName) {
|
||||
commandNames = append(commandNames, command.CommandName)
|
||||
}
|
||||
}
|
||||
@@ -398,7 +401,7 @@ func buildInstallList(name, armoryPK string, forceInstallation bool, pendingPack
|
||||
var requestedPackageList []string
|
||||
if name == "all" {
|
||||
requestedPackageList = []string{}
|
||||
allCommands := getCommandsInCache()
|
||||
allCommands := getCommandsInCache(armoryPK)
|
||||
for _, cmdName := range allCommands {
|
||||
if !slices.Contains(installedPackages, cmdName) || forceInstallation {
|
||||
// Check to see if there is a package pending with that name
|
||||
@@ -458,6 +461,15 @@ func installPackageByName(name, armoryPK string, forceInstallation, promptToOver
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(packageInstallList) == 0 && name == "all" {
|
||||
availableCommands := getCommandsInCache(armoryPK)
|
||||
if len(availableCommands) == 0 {
|
||||
con.PrintInfof("No packages or bundles found\n")
|
||||
} else {
|
||||
con.PrintInfof("All available packages are already installed\n")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(packageInstallList) > 0 {
|
||||
for _, packageID := range packageInstallList {
|
||||
entry := packageCacheLookupByID(packageID)
|
||||
|
||||
@@ -21,6 +21,7 @@ package c2profiles
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
@@ -42,6 +43,10 @@ import (
|
||||
"github.com/bishopfox/sliver/protobuf/commonpb"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNoSelection = errors.New("no selection")
|
||||
)
|
||||
|
||||
// C2ProfileCmd list available http profiles
|
||||
func C2ProfileCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
|
||||
profileName, _ := cmd.Flags().GetString("name")
|
||||
@@ -53,7 +58,11 @@ func C2ProfileCmd(cmd *cobra.Command, con *console.SliverClient, args []string)
|
||||
return
|
||||
}
|
||||
if len(httpC2Profiles.Configs) != 1 {
|
||||
profileName = selectC2Profile(httpC2Profiles.Configs)
|
||||
profileName, err = selectC2Profile(httpC2Profiles.Configs)
|
||||
if err != nil {
|
||||
con.PrintErrorf("%s\n", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +133,11 @@ func ExportC2ProfileCmd(cmd *cobra.Command, con *console.SliverClient, args []st
|
||||
return
|
||||
}
|
||||
if len(httpC2Profiles.Configs) != 1 {
|
||||
profileName = selectC2Profile(httpC2Profiles.Configs)
|
||||
profileName, err = selectC2Profile(httpC2Profiles.Configs)
|
||||
if err != nil {
|
||||
con.PrintErrorf("%s\n", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,7 +553,7 @@ func PrintC2Profiles(profile *clientpb.HTTPC2Config, con *console.SliverClient)
|
||||
con.Println("\n")
|
||||
}
|
||||
|
||||
func selectC2Profile(c2profiles []*clientpb.HTTPC2Config) string {
|
||||
func selectC2Profile(c2profiles []*clientpb.HTTPC2Config) (string, error) {
|
||||
c2profile := ""
|
||||
var choices []string
|
||||
for _, c2profile := range c2profiles {
|
||||
@@ -549,7 +562,11 @@ func selectC2Profile(c2profiles []*clientpb.HTTPC2Config) string {
|
||||
|
||||
_ = forms.Select("Select a c2 profile", choices, &c2profile)
|
||||
|
||||
return c2profile
|
||||
if c2profile == "" {
|
||||
return "", ErrNoSelection
|
||||
}
|
||||
|
||||
return c2profile, nil
|
||||
}
|
||||
|
||||
func updateC2Profile(template *assets.HTTPC2Config, urls []string) (*assets.HTTPC2Config, error) {
|
||||
|
||||
@@ -25,12 +25,14 @@ func Commands(con *console.SliverClient) []*cobra.Command {
|
||||
}
|
||||
flags.Bind("", false, executeCmd, func(f *pflag.FlagSet) {
|
||||
f.BoolP("token", "T", false, "execute command with current token (Windows only)")
|
||||
f.BoolP("output", "o", false, "capture command output")
|
||||
f.BoolP("output", "o", true, "capture command output")
|
||||
f.BoolP("save", "s", false, "save output to a file")
|
||||
f.BoolP("loot", "X", false, "save output as loot")
|
||||
f.BoolP("ignore-stderr", "S", false, "don't print STDERR output")
|
||||
f.StringP("stdout", "O", "", "remote path to redirect STDOUT to")
|
||||
f.StringP("stderr", "E", "", "remote path to redirect STDERR to")
|
||||
f.Bool("env-inheritance", true, "inherit environment variables from the current process")
|
||||
f.StringArray("env", nil, "set an environment variable for the child process (key=value)")
|
||||
f.StringP("name", "n", "", "name to assign loot (optional)")
|
||||
f.Uint32P("ppid", "P", 0, "parent process id (optional, Windows only)")
|
||||
f.BoolP("hidden", "H", false, "hide the window of the spawned process (Windows only)")
|
||||
|
||||
@@ -49,6 +49,13 @@ func ExecuteCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
|
||||
saveLoot, _ := cmd.Flags().GetBool("loot")
|
||||
saveOutput, _ := cmd.Flags().GetBool("save")
|
||||
ppid, _ := cmd.Flags().GetUint32("ppid")
|
||||
envInheritance, _ := cmd.Flags().GetBool("env-inheritance")
|
||||
envPairs, _ := cmd.Flags().GetStringArray("env")
|
||||
envVars, err := parseEnvPairs(envPairs)
|
||||
if err != nil {
|
||||
con.PrintErrorf("%s\n", err)
|
||||
return
|
||||
}
|
||||
hostName := getHostname(session, beacon)
|
||||
|
||||
// If the user wants to loot or save the output, we have to capture it regardless of if they specified -o
|
||||
@@ -59,15 +66,19 @@ func ExecuteCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
|
||||
}
|
||||
|
||||
var exec *sliverpb.Execute
|
||||
var err error
|
||||
|
||||
ctrl := make(chan bool)
|
||||
con.PrintInfof("Execute: %s [%s]\n", cmdPath, strings.Join(args, " "))
|
||||
con.SpinUntil(fmt.Sprintf("Executing %s %s ...", cmdPath, strings.Join(args, " ")), ctrl)
|
||||
if token || hidden || ppid != 0 {
|
||||
if (session != nil && session.OS != "windows") || (beacon != nil && beacon.OS != "windows") {
|
||||
con.PrintErrorf("The token, hide window, and ppid options are not valid on %s\n", session.OS)
|
||||
return
|
||||
}
|
||||
if envInheritance || len(envVars) > 0 {
|
||||
con.PrintErrorf("The env and env-inheritance options are not supported with token, hidden, or ppid\n")
|
||||
return
|
||||
}
|
||||
exec, err = con.Rpc.ExecuteWindows(context.Background(), &sliverpb.ExecuteWindowsReq{
|
||||
Request: con.ActiveTarget.Request(cmd),
|
||||
Path: cmdPath,
|
||||
@@ -81,12 +92,14 @@ func ExecuteCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
|
||||
})
|
||||
} else {
|
||||
exec, err = con.Rpc.Execute(context.Background(), &sliverpb.ExecuteReq{
|
||||
Request: con.ActiveTarget.Request(cmd),
|
||||
Path: cmdPath,
|
||||
Args: args,
|
||||
Output: captureOutput,
|
||||
Stderr: stderr,
|
||||
Stdout: stdout,
|
||||
Request: con.ActiveTarget.Request(cmd),
|
||||
Path: cmdPath,
|
||||
Args: args,
|
||||
Output: captureOutput,
|
||||
Stderr: stderr,
|
||||
Stdout: stdout,
|
||||
EnvInheritance: envInheritance,
|
||||
Env: envVars,
|
||||
})
|
||||
}
|
||||
ctrl <- true
|
||||
@@ -179,6 +192,21 @@ func getHostname(session *clientpb.Session, beacon *clientpb.Beacon) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseEnvPairs(envPairs []string) (map[string]string, error) {
|
||||
if len(envPairs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
envVars := make(map[string]string, len(envPairs))
|
||||
for _, pair := range envPairs {
|
||||
parts := strings.SplitN(pair, "=", 2)
|
||||
if len(parts) != 2 || parts[0] == "" {
|
||||
return nil, fmt.Errorf("invalid --env value %q, expected key=value", pair)
|
||||
}
|
||||
envVars[parts[0]] = parts[1]
|
||||
}
|
||||
return envVars, nil
|
||||
}
|
||||
|
||||
func determineCommandName(command string) string {
|
||||
commandName := strings.ReplaceAll(command, "\\", "/")
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ package exit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
@@ -32,26 +31,25 @@ import (
|
||||
|
||||
// ExitCmd - Exit the console.
|
||||
func ExitCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
|
||||
fmt.Println("Exiting...")
|
||||
if con.IsServer {
|
||||
sessions, err := con.Rpc.GetSessions(context.Background(), &commonpb.Empty{})
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
flushAndExit(con, 1)
|
||||
}
|
||||
beacons, err := con.Rpc.GetBeacons(context.Background(), &commonpb.Empty{})
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
flushAndExit(con, 1)
|
||||
}
|
||||
if 0 < len(sessions.Sessions) || 0 < len(beacons.Beacons) {
|
||||
con.Printf("There are %d active sessions and %d active beacons.\n", len(sessions.Sessions), len(beacons.Beacons))
|
||||
confirm := false
|
||||
_ = forms.Confirm("Are you sure you want to exit?", &confirm)
|
||||
forms.Confirm("Are you sure you want to exit?", &confirm)
|
||||
if !confirm {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
os.Exit(0)
|
||||
flushAndExit(con, 0)
|
||||
}
|
||||
|
||||
// Commands returns the `exit` command.
|
||||
@@ -65,3 +63,12 @@ func Command(con *console.SliverClient) []*cobra.Command {
|
||||
GroupID: constants.GenericHelpGroup,
|
||||
}}
|
||||
}
|
||||
|
||||
func flushAndExit(con *console.SliverClient, code int) {
|
||||
if con != nil {
|
||||
con.FlushOutput()
|
||||
} else {
|
||||
os.Stdout.Sync()
|
||||
}
|
||||
os.Exit(code)
|
||||
}
|
||||
@@ -30,7 +30,6 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/bishopfox/sliver/client/assets"
|
||||
@@ -43,8 +42,8 @@ import (
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
"github.com/bishopfox/sliver/protobuf/sliverpb"
|
||||
"github.com/bishopfox/sliver/util"
|
||||
"github.com/carapace-sh/carapace"
|
||||
appConsole "github.com/reeflective/console"
|
||||
"github.com/rsteube/carapace"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
"google.golang.org/protobuf/proto"
|
||||
@@ -121,6 +120,7 @@ type extensionArgument struct {
|
||||
Desc string `json:"desc"`
|
||||
Optional bool `json:"optional"`
|
||||
Default interface{} `json:"default,omitempty"`
|
||||
Choices []string `json:"choices,omitempty"`
|
||||
}
|
||||
|
||||
func (e *ExtCommand) getFileForTarget(targetOS string, targetArch string) (string, error) {
|
||||
@@ -689,94 +689,6 @@ func PrintExtOutput(extName string, commandName string, outputSchema *packages.O
|
||||
}
|
||||
}
|
||||
|
||||
func getExtArgs(_ *cobra.Command, args []string, _ string, ext *ExtCommand) ([]byte, error) {
|
||||
var err error
|
||||
argsBuffer := core.BOFArgsBuffer{
|
||||
Buffer: new(bytes.Buffer),
|
||||
}
|
||||
|
||||
// Parse BOF arguments from grumble
|
||||
missingRequiredArgs := make([]string, 0)
|
||||
|
||||
// If we have an extension that expects a single string, but more than one has been parsed, combine them
|
||||
if len(ext.Arguments) == 1 && strings.Contains(ext.Arguments[0].Type, "string") && len(args) > 0 {
|
||||
// The loop below will only read the first element of args because ext.Arguments is 1
|
||||
args[0] = strings.Join(args, " ")
|
||||
}
|
||||
|
||||
for _, arg := range ext.Arguments {
|
||||
// If we don't have any positional words left to consume,
|
||||
// add the remaining required extension arguments in the
|
||||
// error message.
|
||||
if len(args) == 0 {
|
||||
if !arg.Optional {
|
||||
missingRequiredArgs = append(missingRequiredArgs, "`"+arg.Name+"`")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Else pop a word from the list
|
||||
word := args[0]
|
||||
args = args[1:]
|
||||
|
||||
switch arg.Type {
|
||||
case "integer":
|
||||
fallthrough
|
||||
case "int":
|
||||
val, err := strconv.Atoi(word)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = argsBuffer.AddInt(uint32(val))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case "short":
|
||||
val, err := strconv.Atoi(word)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = argsBuffer.AddShort(uint16(val))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case "string":
|
||||
err = argsBuffer.AddString(word)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case "wstring":
|
||||
err = argsBuffer.AddWString(word)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Adding support for filepaths so we can
|
||||
// send binary data like shellcodes to BOFs
|
||||
case "file":
|
||||
data, err := os.ReadFile(word)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = argsBuffer.AddData(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return if we have missing required arguments
|
||||
if len(missingRequiredArgs) > 0 {
|
||||
return nil, fmt.Errorf("required arguments %s were not provided", strings.Join(missingRequiredArgs, ", "))
|
||||
}
|
||||
|
||||
parsedArgs, err := argsBuffer.GetBuffer()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return parsedArgs, nil
|
||||
}
|
||||
|
||||
func getBOFArgs(cmd *cobra.Command, args []string, binPath string, ext *ExtCommand) ([]byte, error) {
|
||||
var extensionArgs []byte
|
||||
binData, err := os.ReadFile(binPath)
|
||||
@@ -837,16 +749,27 @@ func checkExtensionArgs(extCmd *ExtCommand) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// makeExtensionArgCompleter builds the positional arguments completer for the extension.
|
||||
// makeExtensionArgCompleter builds the positional and dash arguments completer for the extension.
|
||||
// It provides completion for:
|
||||
// 1. Positional arguments (before --)
|
||||
// 2. Flag-style arguments after -- (e.g., --process, --shellcode)
|
||||
func makeExtensionArgCompleter(extCmd *ExtCommand, _ *cobra.Command, comps *carapace.Carapace) {
|
||||
var actions []carapace.Action
|
||||
|
||||
for _, arg := range extCmd.Arguments {
|
||||
var action carapace.Action
|
||||
|
||||
switch arg.Type {
|
||||
case "file":
|
||||
action = carapace.ActionFiles().Tag("extension data")
|
||||
// If choices are defined, use them for completion
|
||||
if len(arg.Choices) > 0 {
|
||||
action = carapace.ActionValues(arg.Choices...).Tag("choices")
|
||||
} else {
|
||||
// Fall back to type-based completion
|
||||
switch arg.Type {
|
||||
case "file":
|
||||
action = carapace.ActionFiles().Tag("extension data")
|
||||
default:
|
||||
action = carapace.ActionValues()
|
||||
}
|
||||
}
|
||||
|
||||
usage := fmt.Sprintf("(%s) %s", arg.Type, arg.Desc)
|
||||
@@ -858,6 +781,72 @@ func makeExtensionArgCompleter(extCmd *ExtCommand, _ *cobra.Command, comps *cara
|
||||
}
|
||||
|
||||
comps.PositionalCompletion(actions...)
|
||||
|
||||
// Add dash completion for flag-style arguments after --
|
||||
// The extension arguments are parsed as flags (--name value) by argparser.go
|
||||
if len(extCmd.Arguments) > 0 {
|
||||
// Pre-build the flag completions at registration time (not in a callback)
|
||||
// This ensures the values are captured correctly
|
||||
flagCompletion := makeExtensionFlagNameCompletion(extCmd)
|
||||
|
||||
// Build value completions for each argument type
|
||||
valueCompletions := make(map[string]carapace.Action)
|
||||
for _, arg := range extCmd.Arguments {
|
||||
// If choices are defined, use them for completion
|
||||
if len(arg.Choices) > 0 {
|
||||
valueCompletions[arg.Name] = carapace.ActionValues(arg.Choices...).Tag("choices")
|
||||
} else {
|
||||
// Fall back to type-based completion
|
||||
switch arg.Type {
|
||||
case "file":
|
||||
valueCompletions[arg.Name] = carapace.ActionFiles().Tag("file path")
|
||||
default:
|
||||
valueCompletions[arg.Name] = carapace.ActionValues()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use DashAnyCompletion with a smart action that determines context
|
||||
comps.DashAnyCompletion(
|
||||
carapace.ActionCallback(func(c carapace.Context) carapace.Action {
|
||||
// If typing a flag (starts with -)
|
||||
if strings.HasPrefix(c.Value, "-") {
|
||||
return flagCompletion
|
||||
}
|
||||
|
||||
// If previous arg was a flag, complete its value
|
||||
if len(c.Args) > 0 {
|
||||
lastArg := c.Args[len(c.Args)-1]
|
||||
if strings.HasPrefix(lastArg, "-") {
|
||||
flagName := strings.TrimLeft(lastArg, "-")
|
||||
if action, ok := valueCompletions[flagName]; ok {
|
||||
return action
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default: show flag names
|
||||
return flagCompletion
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// makeExtensionFlagNameCompletion creates completion for flag names
|
||||
func makeExtensionFlagNameCompletion(extCmd *ExtCommand) carapace.Action {
|
||||
var results []string
|
||||
|
||||
for _, arg := range extCmd.Arguments {
|
||||
flagName := fmt.Sprintf("--%s", arg.Name)
|
||||
desc := arg.Desc
|
||||
if arg.Optional {
|
||||
desc = fmt.Sprintf("[optional] %s", desc)
|
||||
}
|
||||
desc = fmt.Sprintf("(%s) %s", arg.Type, desc)
|
||||
results = append(results, flagName, desc)
|
||||
}
|
||||
|
||||
return carapace.ActionValuesDescribed(results...).Tag("extension arguments")
|
||||
}
|
||||
|
||||
func makeCommandPlatformFilters(extCmd *ExtCommand) map[string]string {
|
||||
|
||||
@@ -85,6 +85,7 @@ var (
|
||||
|
||||
ErrNoExternalBuilder = errors.New("no external builders are available")
|
||||
ErrNoValidBuilders = errors.New("no valid external builders for target")
|
||||
ErrNoSelection = errors.New("no selection")
|
||||
)
|
||||
|
||||
// GenerateCmd - The main command used to generate implant binaries
|
||||
@@ -1144,6 +1145,11 @@ func selectExternalBuilder(builders []*clientpb.Builder, _ *console.SliverClient
|
||||
}
|
||||
choice := ""
|
||||
_ = forms.Select("Select an external builder:", choices, &choice)
|
||||
|
||||
if choice == "" {
|
||||
return nil, ErrNoSelection
|
||||
}
|
||||
|
||||
for _, builder := range builders {
|
||||
if builder.Name == choice {
|
||||
return builder, nil
|
||||
|
||||
@@ -283,6 +283,10 @@ func SelectTrafficEncoder(con *console.SliverClient) string {
|
||||
}
|
||||
sort.Strings(encoderNames)
|
||||
var selectedEncoder string
|
||||
_ = forms.Select("Select a traffic encoder:", encoderNames, &selectedEncoder)
|
||||
err = forms.Select("Select a traffic encoder:", encoderNames, &selectedEncoder)
|
||||
if err != nil {
|
||||
con.PrintErrorf("%s", err)
|
||||
return ""
|
||||
}
|
||||
return selectedEncoder
|
||||
}
|
||||
@@ -68,6 +68,7 @@ var (
|
||||
consts.RunAsStr: runAsHelp,
|
||||
consts.ImpersonateStr: impersonateHelp,
|
||||
consts.RevToSelfStr: revToSelfHelp,
|
||||
consts.ExecuteStr: executeHelp,
|
||||
consts.ExecuteAssemblyStr: executeAssemblyHelp,
|
||||
consts.ExecuteShellcodeStr: executeShellcodeHelp,
|
||||
consts.MigrateStr: migrateHelp,
|
||||
@@ -124,6 +125,22 @@ var (
|
||||
consts.C2ProfileStr + sep + consts.C2GenerateStr: c2GenerateHelp,
|
||||
}
|
||||
|
||||
executeHelp = `[[.Bold]]Command:[[.Normal]] execute <path to executable> [options] -- [args...]
|
||||
|
||||
[[.Bold]]About:[[.Normal]] Execute a program on the remote system with optional arguments, note the subprocess
|
||||
arguments are be separated from the sliver 'execute' arguments with '--'
|
||||
|
||||
[[.Bold]]Example:[[.Normal]]
|
||||
execute /bin/bash --env FOO=1 -- -c env
|
||||
|
||||
[*] Execute: /bin/bash [-c env]
|
||||
[*] Output:
|
||||
FOO=1
|
||||
PWD=/Users/moloch/git/sliver
|
||||
SHLVL=1
|
||||
_=/usr/bin/env
|
||||
`
|
||||
|
||||
jobsHelp = `[[.Bold]]Command:[[.Normal]] jobs <options>
|
||||
[[.Bold]]About:[[.Normal]] Manage jobs/listeners.`
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
consts "github.com/bishopfox/sliver/client/constants"
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
"github.com/bishopfox/sliver/protobuf/sliverpb"
|
||||
)
|
||||
@@ -44,6 +45,17 @@ func GetSystemCmd(cmd *cobra.Command, con *console.SliverClient, args []string)
|
||||
|
||||
process, _ := cmd.Flags().GetString("process")
|
||||
config := con.GetActiveSessionConfig()
|
||||
|
||||
/* If the HTTP C2 Config name is not defined, then put in the default value
|
||||
This will have no effect on implants that do not use HTTP C2
|
||||
Also this should be overridden when the build info is pulled from the
|
||||
database, but if there is no build info and we have to create the build
|
||||
from scratch, we need to have something in here.
|
||||
*/
|
||||
if config.HTTPC2ConfigName == "" {
|
||||
config.HTTPC2ConfigName = consts.DefaultC2Profile
|
||||
}
|
||||
|
||||
ctrl := make(chan bool)
|
||||
con.SpinUntil("Attempting to create a new sliver session as 'NT AUTHORITY\\SYSTEM'...", ctrl)
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/bishopfox/sliver/client/command/aka"
|
||||
"github.com/bishopfox/sliver/client/command/alias"
|
||||
"github.com/bishopfox/sliver/client/command/armory"
|
||||
"github.com/bishopfox/sliver/client/command/beacons"
|
||||
@@ -102,6 +103,7 @@ func ServerCommands(con *client.SliverClient, serverCmds func() []*cobra.Command
|
||||
crack.Commands,
|
||||
certificates.Commands,
|
||||
clean.Command,
|
||||
aka.ServerCommands,
|
||||
)
|
||||
|
||||
// C2 Network
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/bishopfox/sliver/client/assets"
|
||||
"github.com/bishopfox/sliver/client/command/aka"
|
||||
"github.com/bishopfox/sliver/client/command/alias"
|
||||
"github.com/bishopfox/sliver/client/command/backdoor"
|
||||
"github.com/bishopfox/sliver/client/command/cursed"
|
||||
@@ -78,6 +79,7 @@ func SliverCommands(con *client.SliverClient) console.Commands {
|
||||
// use.Commands,
|
||||
tasks.Commands,
|
||||
pivots.Commands,
|
||||
aka.ImplantCommands,
|
||||
)
|
||||
|
||||
// [ Info ]
|
||||
|
||||
@@ -41,6 +41,7 @@ import (
|
||||
"github.com/bishopfox/sliver/protobuf/rpcpb"
|
||||
"github.com/bishopfox/sliver/util"
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/kballard/go-shellquote"
|
||||
"github.com/reeflective/console"
|
||||
"github.com/reeflective/readline"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -98,8 +99,11 @@ type SliverClient struct {
|
||||
IsServer bool
|
||||
IsCLI bool
|
||||
|
||||
jsonHandler slog.Handler
|
||||
printf func(format string, args ...any) (int, error)
|
||||
jsonHandler slog.Handler
|
||||
printf func(format string, args ...any) (int, error)
|
||||
stdoutPipeWriter *os.File
|
||||
stdoutPipeDone chan struct{}
|
||||
stdoutPipeOnce sync.Once
|
||||
}
|
||||
|
||||
// NewConsole creates the sliver client (and console), creating menus and prompts.
|
||||
@@ -159,7 +163,7 @@ func NewConsole(isServer bool) *SliverClient {
|
||||
// Init requires a working RPC connection to the sliver server, and 2 different sets of commands.
|
||||
// If run is true, the console application is started, making this call blocking. Otherwise, commands and
|
||||
// RPC connection are bound to the console (making the console ready to run), but the console does not start.
|
||||
func StartClient(con *SliverClient, rpc rpcpb.SliverRPCClient, serverCmds, sliverCmds console.Commands, run bool) error {
|
||||
func StartClient(con *SliverClient, rpc rpcpb.SliverRPCClient, serverCmds, sliverCmds console.Commands, run bool, rcScript string) error {
|
||||
con.Rpc = rpc
|
||||
con.IsCLI = !run
|
||||
|
||||
@@ -206,6 +210,13 @@ func StartClient(con *SliverClient, rpc rpcpb.SliverRPCClient, serverCmds, slive
|
||||
con.setupAsciicastRecord(asciicastLog, asciicastStream)
|
||||
}
|
||||
|
||||
if rcScript != "" {
|
||||
originalPrintf := con.printf
|
||||
con.printf = fmt.Printf
|
||||
con.runRCScript(serverCmds, sliverCmds, rcScript)
|
||||
con.printf = originalPrintf
|
||||
}
|
||||
|
||||
if !con.IsCLI {
|
||||
return con.App.Start()
|
||||
}
|
||||
@@ -213,6 +224,71 @@ func StartClient(con *SliverClient, rpc rpcpb.SliverRPCClient, serverCmds, slive
|
||||
return nil
|
||||
}
|
||||
|
||||
func (con *SliverClient) runRCScript(serverCmds, sliverCmds console.Commands, rcScript string) {
|
||||
scanner := bufio.NewScanner(strings.NewReader(rcScript))
|
||||
lineNumber := 0
|
||||
|
||||
for scanner.Scan() {
|
||||
lineNumber++
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if err := con.runRCLine(serverCmds, sliverCmds, line); err != nil {
|
||||
con.PrintErrorf("rc line %d error: %s", lineNumber, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
con.PrintErrorf("rc script error: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (con *SliverClient) runRCLine(serverCmds, sliverCmds console.Commands, line string) error {
|
||||
args, err := shellquote.Split(line)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse error: %w", err)
|
||||
}
|
||||
|
||||
for _, hook := range con.App.PreCmdRunLineHooks {
|
||||
args, err = hook(args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pre-run line error: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(args) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
menu := con.App.ActiveMenu()
|
||||
if menu.Name() == consts.ImplantMenu {
|
||||
menu.Command = sliverCmds()
|
||||
} else {
|
||||
menu.Command = serverCmds()
|
||||
}
|
||||
|
||||
target, _, _ := menu.Command.Find(args)
|
||||
if err := menu.CheckIsAvailable(target); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, hook := range con.App.PreCmdRunHooks {
|
||||
if err := hook(); err != nil {
|
||||
return fmt.Errorf("pre-run error: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
menu.SetArgs(args)
|
||||
menu.SetContext(context.Background())
|
||||
|
||||
if err := menu.Execute(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (con *SliverClient) startEventLoop() {
|
||||
eventStream, err := con.Rpc.Events(context.Background(), &commonpb.Empty{})
|
||||
if err != nil {
|
||||
@@ -341,11 +417,12 @@ func (con *SliverClient) triggerReactions(event *clientpb.Event) {
|
||||
con.ActiveTarget.Set(currentActiveSession, currentActiveBeacon)
|
||||
}()
|
||||
|
||||
if event.EventType == consts.SessionOpenedEvent {
|
||||
switch event.EventType {
|
||||
case consts.SessionOpenedEvent:
|
||||
con.ActiveTarget.Set(nil, nil)
|
||||
|
||||
con.ActiveTarget.Set(event.Session, nil)
|
||||
} else if event.EventType == consts.BeaconRegisteredEvent {
|
||||
case consts.BeaconRegisteredEvent:
|
||||
con.ActiveTarget.Set(nil, nil)
|
||||
|
||||
beacon := &clientpb.Beacon{}
|
||||
@@ -609,6 +686,7 @@ func (c *SliverClient) exitConsole(_ *console.Console) {
|
||||
answer := strings.TrimSpace(text)
|
||||
|
||||
if (answer == "Y") || (answer == "y") {
|
||||
c.FlushOutput()
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
+27
-1
@@ -124,7 +124,14 @@ func (con *SliverClient) setupAsciicastRecord(logFile *os.File, server io.Writer
|
||||
os.Stdout = w
|
||||
os.Stderr = w
|
||||
|
||||
go io.Copy(mw, r)
|
||||
done := make(chan struct{})
|
||||
con.stdoutPipeWriter = w
|
||||
con.stdoutPipeDone = done
|
||||
|
||||
go func() {
|
||||
_, _ = io.Copy(mw, r)
|
||||
close(done)
|
||||
}()
|
||||
}
|
||||
|
||||
func getConsoleLogFile() *os.File {
|
||||
@@ -149,6 +156,25 @@ func getConsoleAsciicastFile() *os.File {
|
||||
return logFile
|
||||
}
|
||||
|
||||
// FlushOutput drains any piped stdout before exiting.
|
||||
func (con *SliverClient) FlushOutput() {
|
||||
if con.stdoutPipeWriter == nil {
|
||||
_ = os.Stdout.Sync()
|
||||
return
|
||||
}
|
||||
|
||||
con.stdoutPipeOnce.Do(func() {
|
||||
_ = con.stdoutPipeWriter.Close()
|
||||
})
|
||||
|
||||
if con.stdoutPipeDone != nil {
|
||||
select {
|
||||
case <-con.stdoutPipeDone:
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// -------------------------- [ Logging ] -----------------------------
|
||||
//
|
||||
|
||||
+23
-3
@@ -5,10 +5,20 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
const defaultSelectHeight = 10
|
||||
|
||||
func getTerminalWidth() int {
|
||||
// Try to get actual terminal width
|
||||
if width, _, err := term.GetSize(1); err == nil && width > 0 {
|
||||
return width
|
||||
}
|
||||
// Fall back to a reasonable default
|
||||
return 200
|
||||
}
|
||||
|
||||
// Confirm prompts for a yes/no answer.
|
||||
func Confirm(title string, value *bool) error {
|
||||
if value == nil {
|
||||
@@ -86,7 +96,7 @@ func MultiSelect(title string, options []string, value *[]string) error {
|
||||
Height(listHeight(len(options))).
|
||||
Value(value)
|
||||
|
||||
form := huh.NewForm(huh.NewGroup(field))
|
||||
form := huh.NewForm(huh.NewGroup(field)).WithWidth(getTerminalWidth())
|
||||
return form.Run()
|
||||
}
|
||||
|
||||
@@ -98,6 +108,8 @@ func selectPrompt(title string, options []string, value *string, required bool)
|
||||
return errors.New("select options are required")
|
||||
}
|
||||
|
||||
// Save the original value in case the form is cancelled
|
||||
originalValue := *value
|
||||
ensureSelectedValue(options, value)
|
||||
|
||||
field := huh.NewSelect[string]().
|
||||
@@ -115,8 +127,16 @@ func selectPrompt(title string, options []string, value *string, required bool)
|
||||
})
|
||||
}
|
||||
|
||||
form := huh.NewForm(huh.NewGroup(field))
|
||||
return form.Run()
|
||||
form := huh.NewForm(huh.NewGroup(field)).WithWidth(getTerminalWidth())
|
||||
err := form.Run()
|
||||
|
||||
// On error restore the originalValue and return err
|
||||
if err != nil {
|
||||
*value = originalValue
|
||||
return err
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func ensureSelectedValue(options []string, value *string) {
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
module github.com/bishopfox/sliver
|
||||
|
||||
go 1.25.3
|
||||
go 1.25.5
|
||||
|
||||
replace github.com/rsteube/carapace v0.36.3 => github.com/reeflective/carapace v0.46.3-0.20231214181515-27e49f3c3b69
|
||||
|
||||
replace github.com/reeflective/readline => github.com/moloch--/readline v0.0.0-20260104185423-d3ac1d6c561d
|
||||
|
||||
require (
|
||||
filippo.io/age v1.2.1
|
||||
filippo.io/age v1.3.1
|
||||
github.com/Binject/binjection v0.0.0-20210701074423-605d46e35deb
|
||||
github.com/Binject/debug v0.0.0-20230508195519-26db73212a7a
|
||||
github.com/Binject/go-donut v0.0.0-20220908180326-fcdcc35d591c
|
||||
github.com/Binject/go-donut v0.0.0-20210701074227-67a31e2d883e
|
||||
github.com/Binject/universal v0.0.0-20220519011857-bea739e758c0
|
||||
github.com/Ne0nd0g/go-clr v1.0.3
|
||||
github.com/alecthomas/chroma v0.10.0
|
||||
@@ -25,54 +27,57 @@ require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0
|
||||
github.com/jedib0t/go-pretty/v6 v6.6.8
|
||||
github.com/jedib0t/go-pretty/v6 v6.7.8
|
||||
github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018
|
||||
github.com/klauspost/compress v1.18.0
|
||||
github.com/klauspost/compress v1.18.1
|
||||
github.com/lesnuages/go-winio v0.4.19
|
||||
github.com/lesnuages/snitch v0.6.0
|
||||
github.com/mattn/go-sqlite3 v1.14.32
|
||||
github.com/miekg/dns v1.1.68
|
||||
github.com/miekg/dns v1.1.70
|
||||
github.com/moloch--/asciicast v0.1.1
|
||||
github.com/moloch--/memmod v0.0.0-20230225130813-fd77d905589e
|
||||
github.com/moloch--/sgn v0.0.4
|
||||
github.com/ncruces/go-sqlite3 v0.29.1
|
||||
github.com/ncruces/go-sqlite3 v0.30.4
|
||||
github.com/reeflective/console v0.1.25
|
||||
github.com/reeflective/readline v1.1.3
|
||||
github.com/rsteube/carapace v0.50.2
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/cobra v1.10.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/pflag v1.0.10
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/tetratelabs/wazero v1.9.0
|
||||
github.com/tetratelabs/wazero v1.11.0
|
||||
github.com/things-go/go-socks5 v0.1.0
|
||||
github.com/ulikunitz/xz v0.5.15
|
||||
github.com/xlab/treeprint v1.2.0
|
||||
github.com/yiya1989/sshkrb5 v0.0.1
|
||||
golang.org/x/crypto v0.46.0
|
||||
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9
|
||||
golang.org/x/mod v0.30.0
|
||||
golang.org/x/net v0.47.0
|
||||
golang.org/x/sys v0.39.0
|
||||
golang.org/x/mod v0.31.0
|
||||
golang.org/x/net v0.48.0
|
||||
golang.org/x/sys v0.40.0
|
||||
golang.org/x/term v0.38.0
|
||||
golang.org/x/text v0.32.0
|
||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
|
||||
google.golang.org/grpc v1.76.0
|
||||
google.golang.org/protobuf v1.36.10
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.0
|
||||
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c
|
||||
modernc.org/sqlite v1.39.0
|
||||
tailscale.com v1.90.6
|
||||
tailscale.com v1.92.5
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/hpke v0.4.0 // indirect
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/buger/jsonparser v1.1.1 // indirect
|
||||
github.com/creachadair/msync v0.7.1 // indirect
|
||||
github.com/invopop/jsonschema v0.13.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/pires/go-proxyproto v0.8.1 // indirect
|
||||
github.com/spf13/cast v1.7.1 // indirect
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
@@ -140,7 +145,7 @@ require (
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/jsimonetti/rtnetlink v1.4.2 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect
|
||||
github.com/mark3labs/mcp-go v0.43.2
|
||||
@@ -180,7 +185,7 @@ require (
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/time v0.13.0 // indirect
|
||||
golang.org/x/tools v0.39.0 // indirect
|
||||
golang.org/x/tools v0.40.0 // indirect
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f // indirect
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f h1:1C7nZuxUMNz7eiQALRfiqNOm04+m3edWlRff/BYHf0Q=
|
||||
9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f/go.mod h1:hHyrZRryGqVdqrknjq5OWDLGCTJ2NeEvtrpR96mjraM=
|
||||
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3IqwfuN5kgDfo5MLzpNM0=
|
||||
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w=
|
||||
c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M=
|
||||
c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU=
|
||||
cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo=
|
||||
filippo.io/age v1.2.1 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o=
|
||||
filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004=
|
||||
filippo.io/age v1.3.1 h1:hbzdQOJkuaMEpRCLSN1/C5DX74RPcNCk6oqhKMXmZi0=
|
||||
filippo.io/age v1.3.1/go.mod h1:EZorDTYUxt836i3zdori5IJX/v2Lj6kWFU0cfh6C0D4=
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
|
||||
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
|
||||
filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc=
|
||||
filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA=
|
||||
github.com/Binject/binjection v0.0.0-20210701074423-605d46e35deb h1:c1Bb1n1wr2xgTHkNVxkUHBshff33m+IXgY6lE6oYttg=
|
||||
@@ -17,8 +19,8 @@ github.com/Binject/debug v0.0.0-20210225042342-c9b8b45728d2/go.mod h1:QzgxDLY/qd
|
||||
github.com/Binject/debug v0.0.0-20210312092933-6277045c2fdf/go.mod h1:QzgxDLY/qdKlvnbnb65eqTedhvQPbaSP2NqIbcuKvsQ=
|
||||
github.com/Binject/debug v0.0.0-20230508195519-26db73212a7a h1:4c0nc0krv8eh7gD809n+swLaCuFyHpxdrxwx0ZmHvBw=
|
||||
github.com/Binject/debug v0.0.0-20230508195519-26db73212a7a/go.mod h1:QzgxDLY/qdKlvnbnb65eqTedhvQPbaSP2NqIbcuKvsQ=
|
||||
github.com/Binject/go-donut v0.0.0-20220908180326-fcdcc35d591c h1:cFMQKxryHZ3Sddca8nqJ1RDjaUTW/+79lRKs+7508nk=
|
||||
github.com/Binject/go-donut v0.0.0-20220908180326-fcdcc35d591c/go.mod h1:dc3mUnr4KTKcFKVq7BVbHGF0xAHrIyooQ+VTO7/bIZw=
|
||||
github.com/Binject/go-donut v0.0.0-20210701074227-67a31e2d883e h1:ytVmxGQuS7ELO/WpvH6iuY1hVcJ6iOTw3VLOOIFlo8o=
|
||||
github.com/Binject/go-donut v0.0.0-20210701074227-67a31e2d883e/go.mod h1:dc3mUnr4KTKcFKVq7BVbHGF0xAHrIyooQ+VTO7/bIZw=
|
||||
github.com/Binject/shellcode v0.0.0-20191101084904-a8a90e7d4563 h1:T8z8Wz/fqaPPANF8Unv4LjWLoa0TAvsjYAsRpvSkOqs=
|
||||
github.com/Binject/shellcode v0.0.0-20191101084904-a8a90e7d4563/go.mod h1:HMbzsKPz1sF7H4Hmeovh+d2PH2iKPYpAB4XMOVz8wmM=
|
||||
github.com/Binject/universal v0.0.0-20220519011857-bea739e758c0 h1:1sdnMvqxxlMXsu8ku/Xrh6gi3V40ykksst5fd0lfWnc=
|
||||
@@ -146,6 +148,8 @@ github.com/coreos/go-iptables v0.8.0 h1:MPc2P89IhuVpLI7ETL/2tx3XZ61VeICZjYqDEgNs
|
||||
github.com/coreos/go-iptables v0.8.0/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creachadair/msync v0.7.1 h1:SeZmuEBXQPe5GqV/C94ER7QIZPwtvFbeQiykzt/7uho=
|
||||
github.com/creachadair/msync v0.7.1/go.mod h1:8CcFlLsSujfHE5wWm19uUBLHIPDAUr6LXDwneVMO008=
|
||||
github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc=
|
||||
github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
@@ -270,8 +274,8 @@ github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFr
|
||||
github.com/jcmturner/gofork v1.0.0/go.mod h1:MK8+TM0La+2rjBD4jE12Kj1pCCxK7d2LK/UM3ncEo0o=
|
||||
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
|
||||
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
|
||||
github.com/jedib0t/go-pretty/v6 v6.6.8 h1:JnnzQeRz2bACBobIaa/r+nqjvws4yEhcmaZ4n1QzsEc=
|
||||
github.com/jedib0t/go-pretty/v6 v6.6.8/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
|
||||
github.com/jedib0t/go-pretty/v6 v6.7.8 h1:BVYrDy5DPBA3Qn9ICT+PokP9cvCv1KaHv2i+Hc8sr5o=
|
||||
github.com/jedib0t/go-pretty/v6 v6.7.8/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
|
||||
github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g=
|
||||
github.com/jellydator/ttlcache/v3 v3.1.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4=
|
||||
github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4=
|
||||
@@ -289,8 +293,8 @@ github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018 h1:NQYgMY188uWr
|
||||
github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018/go.mod h1:Pmpz2BLf55auQZ67u3rvyI2vAQvNetkK/4zYUmpauZQ=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
||||
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ=
|
||||
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk=
|
||||
@@ -338,8 +342,8 @@ github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ
|
||||
github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE=
|
||||
github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
|
||||
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
|
||||
github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA=
|
||||
github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps=
|
||||
github.com/miekg/dns v1.1.70 h1:DZ4u2AV35VJxdD9Fo9fIWm119BsQL5cZU1cQ9s0LkqA=
|
||||
github.com/miekg/dns v1.1.70/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
|
||||
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||
@@ -350,6 +354,8 @@ github.com/moloch--/go-keystone v0.0.2 h1:xThbYs45CSzoDUz5kBZxAyTPIpK3t5M7NB1x+N
|
||||
github.com/moloch--/go-keystone v0.0.2/go.mod h1:kpGRJpw9b7Eq3MluHrba+GPKZhSyDrvhLULSwH5nGGM=
|
||||
github.com/moloch--/memmod v0.0.0-20230225130813-fd77d905589e h1:IkFCPlAa0iTiLxck+NqAwBx8JDlnHYm4orOQBbs4BDQ=
|
||||
github.com/moloch--/memmod v0.0.0-20230225130813-fd77d905589e/go.mod h1:eYeI6cQ5YHhHt9i0BBW0zc1DaQnb4ZMXsSPuEV/V5Og=
|
||||
github.com/moloch--/readline v0.0.0-20260104185423-d3ac1d6c561d h1:9RbKockJl6ZdgoEh2QqU3An6Ij/NWZtRWS3J6yG0+qo=
|
||||
github.com/moloch--/readline v0.0.0-20260104185423-d3ac1d6c561d/go.mod h1:CwNkh9BmFBBCSO6mdDaNWb34rOqQsI9eYbxyqvOEazY=
|
||||
github.com/moloch--/sgn v0.0.4 h1:90IC5rx3TmPEfrqPnENB0yyiqcbcNk9LqezXx7uruF0=
|
||||
github.com/moloch--/sgn v0.0.4/go.mod h1:FyWfCtdynywgLmVR71vTJztZ3WkGSkR91Y4EOijUutE=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
@@ -360,8 +366,8 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/ncruces/go-sqlite3 v0.29.1 h1:NIi8AISWBToRHyoz01FXiTNvU147Tqdibgj2tFzJCqM=
|
||||
github.com/ncruces/go-sqlite3 v0.29.1/go.mod h1:PpccBNNhvjwUOwDQEn2gXQPFPTWdlromj0+fSkd5KSg=
|
||||
github.com/ncruces/go-sqlite3 v0.30.4 h1:j9hEoOL7f9ZoXl8uqXVniaq1VNwlWAXihZbTvhqPPjA=
|
||||
github.com/ncruces/go-sqlite3 v0.30.4/go.mod h1:7WR20VSC5IZusKhUdiR9y1NsUqnZgqIYCmKKoMEYg68=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
|
||||
@@ -375,6 +381,8 @@ github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhA
|
||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
|
||||
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0=
|
||||
github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo=
|
||||
@@ -385,14 +393,12 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH
|
||||
github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4=
|
||||
github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
|
||||
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
|
||||
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
|
||||
github.com/reeflective/console v0.1.25 h1:do/r6rcKH6pCQDEzh5PhoVFcqHkqcjd0N6tNjpmYWWQ=
|
||||
github.com/reeflective/console v0.1.25/go.mod h1:It/IqEjo0PowaBuxBS9pEi5EYUgmtvQd0ytMtlS77kk=
|
||||
github.com/reeflective/readline v1.1.3 h1:meGkuEmujZHmalJ9eT3pYkwtkufH5EwYFPTnaph0T0s=
|
||||
github.com/reeflective/readline v1.1.3/go.mod h1:CwNkh9BmFBBCSO6mdDaNWb34rOqQsI9eYbxyqvOEazY=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
@@ -414,8 +420,8 @@ github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
||||
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
@@ -454,8 +460,8 @@ github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp
|
||||
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg=
|
||||
github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=
|
||||
github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk=
|
||||
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
|
||||
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
|
||||
github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
|
||||
github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
|
||||
github.com/thedevsaddam/gojsonq/v2 v2.5.2 h1:CoMVaYyKFsVj6TjU6APqAhAvC07hTI6IQen8PHzHYY0=
|
||||
github.com/thedevsaddam/gojsonq/v2 v2.5.2/go.mod h1:bv6Xa7kWy82uT0LnXPE2SzGqTj33TAEeR560MdJkiXs=
|
||||
github.com/things-go/go-socks5 v0.1.0 h1:4f5dz0iMQ6cA4wseFmyLmCHmg3SWJTW92ndrKS6oERg=
|
||||
@@ -498,6 +504,7 @@ go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
|
||||
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
|
||||
go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
|
||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
||||
@@ -521,8 +528,8 @@ golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHl
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -531,8 +538,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
@@ -561,8 +568,8 @@ golang.org/x/sys v0.0.0-20220330033206-e17cdc41300f/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
||||
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
@@ -581,8 +588,8 @@ golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtn
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -611,8 +618,8 @@ google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8
|
||||
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
|
||||
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
|
||||
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
@@ -645,8 +652,8 @@ gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgI
|
||||
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.5.1 h1:4bH5o3b5ZULQ4UrBmP+63W9r7qIkqJClEA9ko5YKx+I=
|
||||
honnef.co/go/tools v0.5.1/go.mod h1:e9irvo83WDG9/irijV44wr3tbhcFeRnfpVlRqVwpzMs=
|
||||
honnef.co/go/tools v0.7.0-0.dev.0.20251022135355-8273271481d0 h1:5SXjd4ET5dYijLaf0O3aOenC0Z4ZafIWSpjUzsQaNho=
|
||||
honnef.co/go/tools v0.7.0-0.dev.0.20251022135355-8273271481d0/go.mod h1:EPDDhEZqVHhWuPI5zPAsjU0U7v9xNIWjoOVyZ5ZcniQ=
|
||||
howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
|
||||
howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
|
||||
@@ -679,5 +686,5 @@ mvdan.cc/sh/v3 v3.12.0 h1:ejKUR7ONP5bb+UGHGEG/k9V5+pRVIyD+LsZz7o8KHrI=
|
||||
mvdan.cc/sh/v3 v3.12.0/go.mod h1:Se6Cj17eYSn+sNooLZiEUnNNmNxg0imoYlTu4CyaGyg=
|
||||
software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k=
|
||||
software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
|
||||
tailscale.com v1.90.6 h1:EhYPiZP/xcLeinikaLA0kn4CQT3+z9SZ13IB/kzWhd4=
|
||||
tailscale.com v1.90.6/go.mod h1:+9EX6pOGCNa6pxCVRhhlJLy/qnkDzOplFYpeZyYlCT0=
|
||||
tailscale.com v1.92.5 h1:h88HqtGk8jd9umF7l6m/8+QK69dY9IVMIlkNK9XYpvQ=
|
||||
tailscale.com v1.92.5/go.mod h1:jzTfKDd6XNNSNoX+Q9INIMpMU5rfZ9g8ojcAoMKi5w0=
|
||||
@@ -27,10 +27,12 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"maps"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
@@ -1146,6 +1148,29 @@ func executeHandler(data []byte, resp RPCResponse) {
|
||||
return
|
||||
}
|
||||
cmd := exec.Command(exePath, execReq.Args...)
|
||||
if execReq.EnvInheritance || len(execReq.Env) > 0 {
|
||||
envVars := make(map[string]string)
|
||||
if execReq.EnvInheritance {
|
||||
for _, entry := range os.Environ() {
|
||||
parts := strings.SplitN(entry, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
envVars[parts[0]] = parts[1]
|
||||
} else if len(parts) == 1 {
|
||||
envVars[parts[0]] = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
maps.Copy(envVars, execReq.Env)
|
||||
keys := make([]string, 0, len(envVars))
|
||||
for key := range envVars {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
cmd.Env = make([]string, 0, len(envVars))
|
||||
for _, key := range keys {
|
||||
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", key, envVars[key]))
|
||||
}
|
||||
}
|
||||
|
||||
if execReq.Output {
|
||||
stdOutBuff := new(bytes.Buffer)
|
||||
|
||||
@@ -251,17 +251,19 @@ func beaconMainLoop(beacon *transports.Beacon) error {
|
||||
time.Sleep(time.Second)
|
||||
beacon.Close()
|
||||
|
||||
// BeaconMain - Is executed in it's own goroutine as the function will block
|
||||
// until all tasks complete (in success or failure), if a task handler blocks
|
||||
// forever it will simply block this set of tasks instead of the entire beacon
|
||||
// BeaconMain - Tasks run in background goroutines, check-ins happen on schedule
|
||||
// Results are collected and sent on subsequent check-ins
|
||||
pendingResults := make(chan *sliverpb.Envelope, 100)
|
||||
errors := make(chan error)
|
||||
shortCircuit := make(chan struct{})
|
||||
|
||||
for {
|
||||
duration := beacon.Duration()
|
||||
nextCheckin = time.Now().Add(duration)
|
||||
|
||||
go func() {
|
||||
oldInterval := beacon.Interval()
|
||||
err := beaconMain(beacon, nextCheckin)
|
||||
err := beaconMain(beacon, nextCheckin, pendingResults)
|
||||
if err != nil {
|
||||
// {{if .Config.Debug}}
|
||||
log.Printf("[beacon] main error: %v", nextCheckin)
|
||||
@@ -289,7 +291,7 @@ func beaconMainLoop(beacon *transports.Beacon) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func beaconMain(beacon *transports.Beacon, nextCheckin time.Time) error {
|
||||
func beaconMain(beacon *transports.Beacon, nextCheckin time.Time, pendingResults chan *sliverpb.Envelope) error {
|
||||
err := beacon.Start()
|
||||
if err != nil {
|
||||
// {{if .Config.Debug}}
|
||||
@@ -304,12 +306,26 @@ func beaconMain(beacon *transports.Beacon, nextCheckin time.Time) error {
|
||||
time.Sleep(time.Second)
|
||||
beacon.Close()
|
||||
}()
|
||||
|
||||
// Collect any pending results from background tasks
|
||||
var completedResults []*sliverpb.Envelope
|
||||
collecting:
|
||||
for {
|
||||
select {
|
||||
case result := <-pendingResults:
|
||||
completedResults = append(completedResults, result)
|
||||
default:
|
||||
break collecting
|
||||
}
|
||||
}
|
||||
|
||||
// {{if .Config.Debug}}
|
||||
log.Printf("[beacon] sending check in ...")
|
||||
log.Printf("[beacon] sending check in with %d pending results...", len(completedResults))
|
||||
// {{end}}
|
||||
err = beacon.Send(wrapEnvelope(sliverpb.MsgBeaconTasks, &sliverpb.BeaconTasks{
|
||||
ID: InstanceID,
|
||||
NextCheckin: int64(beacon.Duration().Seconds()),
|
||||
Tasks: completedResults,
|
||||
}))
|
||||
if err != nil {
|
||||
// {{if .Config.Debug}}
|
||||
@@ -361,30 +377,81 @@ func beaconMain(beacon *transports.Beacon, nextCheckin time.Time) error {
|
||||
}
|
||||
}
|
||||
|
||||
// ensure extensions are registered before they are called
|
||||
var results []*sliverpb.Envelope
|
||||
// Dispatch tasks to background - results sent to pendingResults as each completes
|
||||
// Extensions must be registered synchronously before other tasks can use them
|
||||
for _, r := range beaconHandleTasklist(tasksExtensionRegister) {
|
||||
results = append(results, r)
|
||||
}
|
||||
for _, r := range beaconHandleTasklist(tasksOther) {
|
||||
results = append(results, r)
|
||||
pendingResults <- r
|
||||
}
|
||||
|
||||
err = beacon.Send(wrapEnvelope(sliverpb.MsgBeaconTasks, &sliverpb.BeaconTasks{
|
||||
ID: InstanceID,
|
||||
Tasks: results,
|
||||
}))
|
||||
if err != nil {
|
||||
// {{if .Config.Debug}}
|
||||
log.Printf("[beacon] error sending results %s", err)
|
||||
// {{end}}
|
||||
}
|
||||
// Dispatch other tasks individually - each sends its result when done
|
||||
beaconDispatchTasks(tasksOther, pendingResults)
|
||||
|
||||
// {{if .Config.Debug}}
|
||||
log.Printf("[beacon] all results sent to server, cleanup ...")
|
||||
log.Printf("[beacon] tasks dispatched to background, check-in complete")
|
||||
// {{end}}
|
||||
return nil
|
||||
}
|
||||
|
||||
// beaconDispatchTasks dispatches tasks to run in background, sending results as each completes
|
||||
func beaconDispatchTasks(tasks []*sliverpb.Envelope, pendingResults chan *sliverpb.Envelope) {
|
||||
sysHandlers := handlers.GetSystemHandlers()
|
||||
specHandlers := handlers.GetKillHandlers()
|
||||
|
||||
for _, task := range tasks {
|
||||
// {{if .Config.Debug}}
|
||||
log.Printf("[beacon] dispatching task %d", task.Type)
|
||||
// {{end}}
|
||||
if handler, ok := sysHandlers[task.Type]; ok {
|
||||
data := task.Data
|
||||
taskID := task.ID
|
||||
// {{if eq .Config.GOOS "windows" }}
|
||||
go func() {
|
||||
handlers.WrapperHandler(handler, data, func(data []byte, err error) {
|
||||
// {{if .Config.Debug}}
|
||||
if err != nil {
|
||||
log.Printf("[beacon] handler function returned an error: %s", err)
|
||||
}
|
||||
log.Printf("[beacon] task completed (id: %d)", taskID)
|
||||
// {{end}}
|
||||
pendingResults <- &sliverpb.Envelope{
|
||||
ID: taskID,
|
||||
Data: data,
|
||||
}
|
||||
})
|
||||
}()
|
||||
// {{else}}
|
||||
go func() {
|
||||
handler(data, func(data []byte, err error) {
|
||||
// {{if .Config.Debug}}
|
||||
if err != nil {
|
||||
log.Printf("[beacon] handler function returned an error: %s", err)
|
||||
}
|
||||
log.Printf("[beacon] task completed (id: %d)", taskID)
|
||||
// {{end}}
|
||||
pendingResults <- &sliverpb.Envelope{
|
||||
ID: taskID,
|
||||
Data: data,
|
||||
}
|
||||
})
|
||||
}()
|
||||
// {{end}}
|
||||
} else if task.Type == sliverpb.MsgOpenSession {
|
||||
go openSessionHandler(task.Data)
|
||||
pendingResults <- &sliverpb.Envelope{
|
||||
ID: task.ID,
|
||||
Data: []byte{},
|
||||
}
|
||||
} else if handler, ok := specHandlers[task.Type]; ok {
|
||||
go handler(task.Data, nil)
|
||||
} else {
|
||||
pendingResults <- &sliverpb.Envelope{
|
||||
ID: task.ID,
|
||||
UnknownMessageType: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func beaconHandleTasklist(tasks []*sliverpb.Envelope) []*sliverpb.Envelope {
|
||||
results := []*sliverpb.Envelope{}
|
||||
resultsMutex := &sync.Mutex{}
|
||||
|
||||
@@ -362,9 +362,6 @@ func dnsBeacon(uri *url.URL) *Beacon {
|
||||
return client.WriteEnvelope(envelope)
|
||||
},
|
||||
Close: func() error {
|
||||
if client != nil {
|
||||
return client.CloseSession()
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Cleanup: func() error {
|
||||
|
||||
+1089
-1062
File diff suppressed because it is too large.
Load diff
@@ -515,6 +515,9 @@ message ExecuteReq {
|
||||
bool Output = 3;
|
||||
string Stdout = 4;
|
||||
string Stderr = 5;
|
||||
bool EnvInheritance = 6;
|
||||
map<string, string> Env = 7;
|
||||
|
||||
uint32 PPid = 10;
|
||||
|
||||
commonpb.Request Request = 9;
|
||||
|
||||
+10
-1
@@ -28,6 +28,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
clientcli "github.com/bishopfox/sliver/client/cli"
|
||||
"github.com/bishopfox/sliver/server/assets"
|
||||
"github.com/bishopfox/sliver/server/c2"
|
||||
"github.com/bishopfox/sliver/server/certs"
|
||||
@@ -72,6 +73,8 @@ func initConsoleLogging(appDir string) *os.File {
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.Flags().String(clientcli.RCFlagName, "", "path to rc script file")
|
||||
|
||||
// Unpack
|
||||
unpackCmd.Flags().BoolP(forceFlagStr, "f", false, "Force unpack and overwrite")
|
||||
rootCmd.AddCommand(unpackCmd)
|
||||
@@ -147,8 +150,14 @@ var rootCmd = &cobra.Command{
|
||||
if serverConfig.DaemonMode {
|
||||
daemon.Start(daemon.BlankHost, daemon.BlankPort, serverConfig.DaemonConfig.Tailscale)
|
||||
} else {
|
||||
rcScript, err := clientcli.ReadRCScript(cmd)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to read rc script: %s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
os.Args = os.Args[:1] // Hide cli from grumble console
|
||||
console.Start()
|
||||
console.Start(rcScript)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ import (
|
||||
)
|
||||
|
||||
// Start - Starts the server console
|
||||
func Start() {
|
||||
func Start(rcScript string) {
|
||||
_, ln, _ := transport.LocalListener()
|
||||
ctxDialer := grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
|
||||
return ln.Dial()
|
||||
@@ -57,7 +57,7 @@ func Start() {
|
||||
defer conn.Close()
|
||||
localRPC := rpcpb.NewSliverRPCClient(conn)
|
||||
con := console.NewConsole(false)
|
||||
console.StartClient(con, localRPC, command.ServerCommands(con, serverOnlyCmds), command.SliverCommands(con), true)
|
||||
console.StartClient(con, localRPC, command.ServerCommands(con, serverOnlyCmds), command.SliverCommands(con), true, rcScript)
|
||||
|
||||
con.App.Start()
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
Rtunnels map[uint64]*RTunnel = make(map[uint64]*RTunnel)
|
||||
mutex sync.RWMutex
|
||||
pending sync.Map
|
||||
Rtunnels map[uint64]*RTunnel = make(map[uint64]*RTunnel)
|
||||
mutex sync.RWMutex
|
||||
pending map[string]map[string]int
|
||||
listeners map[string]map[uint32]string
|
||||
pendingMu sync.Mutex
|
||||
)
|
||||
|
||||
// RTunnel - Duplex byte read/write
|
||||
@@ -97,20 +99,107 @@ func RemoveRTunnel(ID uint64) {
|
||||
}
|
||||
|
||||
func AddPending(sessionID string, connStr string) {
|
||||
pending.Store(sessionID, connStr)
|
||||
pendingMu.Lock()
|
||||
defer pendingMu.Unlock()
|
||||
if pending == nil {
|
||||
pending = make(map[string]map[string]int)
|
||||
}
|
||||
addrMap := pending[sessionID]
|
||||
if addrMap == nil {
|
||||
addrMap = make(map[string]int)
|
||||
pending[sessionID] = addrMap
|
||||
}
|
||||
addrMap[connStr]++
|
||||
}
|
||||
|
||||
func DeletePending(sessionID string) {
|
||||
pending.Delete(sessionID)
|
||||
func DeletePending(sessionID string, connStr string) {
|
||||
pendingMu.Lock()
|
||||
defer pendingMu.Unlock()
|
||||
if pending == nil {
|
||||
return
|
||||
}
|
||||
if addrMap, ok := pending[sessionID]; ok {
|
||||
if count, ok := addrMap[connStr]; ok {
|
||||
if count <= 1 {
|
||||
delete(addrMap, connStr)
|
||||
} else {
|
||||
addrMap[connStr] = count - 1
|
||||
}
|
||||
}
|
||||
if len(addrMap) == 0 {
|
||||
delete(pending, sessionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func Check(sessionID string, connStr string) bool {
|
||||
if val, ok := pending.Load(sessionID); ok {
|
||||
return val == connStr
|
||||
pendingMu.Lock()
|
||||
defer pendingMu.Unlock()
|
||||
if pending == nil {
|
||||
return false
|
||||
}
|
||||
if addrMap, ok := pending[sessionID]; ok {
|
||||
return addrMap[connStr] > 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TrackListener(sessionID string, listenerID uint32, connStr string) {
|
||||
pendingMu.Lock()
|
||||
defer pendingMu.Unlock()
|
||||
if listeners == nil {
|
||||
listeners = make(map[string]map[uint32]string)
|
||||
}
|
||||
if pending == nil {
|
||||
pending = make(map[string]map[string]int)
|
||||
}
|
||||
lm := listeners[sessionID]
|
||||
if lm == nil {
|
||||
lm = make(map[uint32]string)
|
||||
listeners[sessionID] = lm
|
||||
}
|
||||
lm[listenerID] = connStr
|
||||
addrMap := pending[sessionID]
|
||||
if addrMap == nil {
|
||||
addrMap = make(map[string]int)
|
||||
pending[sessionID] = addrMap
|
||||
}
|
||||
addrMap[connStr]++
|
||||
}
|
||||
|
||||
func UntrackListener(sessionID string, listenerID uint32) bool {
|
||||
pendingMu.Lock()
|
||||
defer pendingMu.Unlock()
|
||||
if listeners == nil || pending == nil {
|
||||
return false
|
||||
}
|
||||
lm, ok := listeners[sessionID]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
connStr, ok := lm[listenerID]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
delete(lm, listenerID)
|
||||
if len(lm) == 0 {
|
||||
delete(listeners, sessionID)
|
||||
}
|
||||
if addrMap, ok := pending[sessionID]; ok {
|
||||
if count, ok := addrMap[connStr]; ok {
|
||||
if count <= 1 {
|
||||
delete(addrMap, connStr)
|
||||
} else {
|
||||
addrMap[connStr] = count - 1
|
||||
}
|
||||
}
|
||||
if len(addrMap) == 0 {
|
||||
delete(pending, sessionID)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// func removeAndCloseAllRTunnels() {
|
||||
// mutex.Lock()
|
||||
// defer mutex.Unlock()
|
||||
|
||||
@@ -533,12 +533,23 @@ func renderSliverGoCode(name string, build *clientpb.ImplantBuild, config *clien
|
||||
goPackage = config.GoPackage
|
||||
}
|
||||
}
|
||||
result, err := goname.RenameModule(sliverPkgDir, goPackage)
|
||||
modResult, err := goname.RenameModule(sliverPkgDir, goPackage)
|
||||
if err != nil {
|
||||
buildLog.Errorf("Failed to rename module: %s", err)
|
||||
return "", err
|
||||
}
|
||||
buildLog.Infof("Renamed module: %s -> %s (go.mod updated: %v)", result.OldModule, result.NewModule, result.GoModUpdated)
|
||||
buildLog.Infof("Renamed module: %s -> %s (go.mod updated: %v)", modResult.OldModule, modResult.NewModule, modResult.GoModUpdated)
|
||||
|
||||
// Rename Import Paths
|
||||
buildLog.Infof("Renaming import paths ...")
|
||||
oldImportPrefix := path.Join(modResult.NewModule, "implant", "sliver")
|
||||
newImportPrefix := path.Join(modResult.NewModule, "runc", "cgroup")
|
||||
importResult, err := goname.RenameImport(sliverPkgDir, oldImportPrefix, newImportPrefix)
|
||||
if err != nil {
|
||||
buildLog.Errorf("Failed to rename imports: %s", err)
|
||||
return "", err
|
||||
}
|
||||
buildLog.Infof("Renamed imports (%d files updated)", importResult.FilesUpdated)
|
||||
}
|
||||
|
||||
return sliverPkgDir, nil
|
||||
|
||||
@@ -2,7 +2,10 @@ package goname
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
@@ -37,6 +40,9 @@ func RenameImport(dir, oldImportPrefix, newImportPrefix string) (*ImportResult,
|
||||
if result.ImportsUpdated == 0 {
|
||||
return result, errors.New("no imports updated (check old/new import prefixes)")
|
||||
}
|
||||
if err := renameImportDir(dir, oldImportPrefix, newImportPrefix); err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -68,3 +74,61 @@ func rewriteImportPrefixes(dir, oldImportPrefix, newImportPrefix string, result
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func renameImportDir(dir, oldImportPrefix, newImportPrefix string) error {
|
||||
oldParts := splitImportPrefix(oldImportPrefix)
|
||||
newParts := splitImportPrefix(newImportPrefix)
|
||||
if len(oldParts) == 0 || len(newParts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for i := 0; i < len(oldParts); i++ {
|
||||
suffixParts := oldParts[i:]
|
||||
oldDir := filepath.Join(append([]string{dir}, suffixParts...)...)
|
||||
info, err := os.Stat(oldDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(newParts) < len(suffixParts) {
|
||||
return fmt.Errorf("new import prefix has fewer path segments than old import prefix")
|
||||
}
|
||||
newSuffixParts := newParts[len(newParts)-len(suffixParts):]
|
||||
newDir := filepath.Join(append([]string{dir}, newSuffixParts...)...)
|
||||
|
||||
if oldDir == newDir {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := os.Stat(newDir); err == nil {
|
||||
return fmt.Errorf("rename import dir: target exists: %s", newDir)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(newDir), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(oldDir, newDir)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func splitImportPrefix(prefix string) []string {
|
||||
cleaned := path.Clean(strings.TrimSpace(prefix))
|
||||
if cleaned == "." || cleaned == "/" {
|
||||
return nil
|
||||
}
|
||||
cleaned = strings.TrimPrefix(cleaned, "/")
|
||||
if cleaned == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(cleaned, "/")
|
||||
}
|
||||
@@ -42,11 +42,15 @@ func TestRenameImportBasic(t *testing.T) {
|
||||
t.Fatalf("main.go still references old prefix")
|
||||
}
|
||||
|
||||
barImports := importPaths(t, filepath.Join(tmp, "foo", "bar", "bar.go"))
|
||||
barImports := importPaths(t, filepath.Join(tmp, "acme", "corp", "bar.go"))
|
||||
if !barImports["acme/corp/baz"] {
|
||||
t.Fatalf("bar.go import not updated")
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(tmp, "foo", "bar")); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected foo/bar to be renamed")
|
||||
}
|
||||
|
||||
vendorImports := importPaths(t, filepath.Join(tmp, "vendor", "foo", "bar", "vendor.go"))
|
||||
if !vendorImports["foo/bar/baz"] {
|
||||
t.Fatalf("vendor import should remain unchanged")
|
||||
|
||||
@@ -231,7 +231,6 @@ func createReverseTunnelHandler(implantConn *core.ImplantConnection, data []byte
|
||||
sessionHandlerLog.Errorf("Session %s attempted to create reverse tunnel to %s without being initiated by a client", session.ID, remoteAddress)
|
||||
return nil
|
||||
}
|
||||
defer rtunnels.DeletePending(session.ID)
|
||||
|
||||
var defaultDialer = new(net.Dialer)
|
||||
|
||||
|
||||
@@ -109,6 +109,12 @@ func (rpc *Server) GetSystem(ctx context.Context, req *clientpb.GetSystemReq) (*
|
||||
if err != nil {
|
||||
req.Config.Format = clientpb.OutputFormat_SHELLCODE
|
||||
req.Config.ObfuscateSymbols = false
|
||||
req.Config.IsShellcode = true
|
||||
req.Config.IsSharedLib = false
|
||||
req.Config.TemplateName = "sliver"
|
||||
if len(req.Config.Exports) == 0 {
|
||||
req.Config.Exports = []string{"StartW"}
|
||||
}
|
||||
build, err := generate.GenerateConfig(name, req.Config)
|
||||
if err != nil {
|
||||
return nil, rpcError(err)
|
||||
|
||||
@@ -39,11 +39,17 @@ func (rpc *Server) GetRportFwdListeners(ctx context.Context, req *sliverpb.Rport
|
||||
// StartRportfwdListener - Instruct the implant to start a reverse port forward
|
||||
func (rpc *Server) StartRportFwdListener(ctx context.Context, req *sliverpb.RportFwdStartListenerReq) (*sliverpb.RportFwdListener, error) {
|
||||
resp := &sliverpb.RportFwdListener{Response: &commonpb.Response{}}
|
||||
rtunnels.AddPending(req.Request.SessionID, req.ForwardAddress)
|
||||
err := rpc.GenericHandler(req, resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Response.GetErr() == "" {
|
||||
addr := req.ForwardAddress
|
||||
if resp.ForwardAddress != "" {
|
||||
addr = resp.ForwardAddress
|
||||
}
|
||||
rtunnels.TrackListener(req.Request.SessionID, resp.ID, addr)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -54,5 +60,8 @@ func (rpc *Server) StopRportFwdListener(ctx context.Context, req *sliverpb.Rport
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Response.GetErr() == "" {
|
||||
rtunnels.UntrackListener(req.Request.SessionID, req.ID)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
# This is the official list of age authors for copyright purposes.
|
||||
# To be included, send a change adding the individual or company
|
||||
# who owns a contribution's copyright.
|
||||
|
||||
Google LLC
|
||||
Filippo Valsorda
|
||||
+2
@@ -1,4 +1,6 @@
|
||||
Copyright 2019 The age Authors
|
||||
Copyright 2019 Google LLC
|
||||
Copyright 2022 Filippo Valsorda
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
|
||||
+67
-45
@@ -12,7 +12,7 @@
|
||||
|
||||
age is a simple, modern and secure file encryption tool, format, and Go library.
|
||||
|
||||
It features small explicit keys, no config options, and UNIX-style composability.
|
||||
It features small explicit keys, post-quantum support, no config options, and UNIX-style composability.
|
||||
|
||||
```
|
||||
$ age-keygen -o key.txt
|
||||
@@ -21,17 +21,17 @@ $ tar cvz ~/data | age -r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9
|
||||
$ age --decrypt -i key.txt data.tar.gz.age > data.tar.gz
|
||||
```
|
||||
|
||||
📜 The format specification is at [age-encryption.org/v1](https://age-encryption.org/v1). age was designed by [@Benjojo12](https://twitter.com/Benjojo12) and [@FiloSottile](https://twitter.com/FiloSottile).
|
||||
|
||||
📬 Follow the maintenance of this project by subscribing to [Maintainer Dispatches](https://filippo.io/newsletter)!
|
||||
📜 The format specification is at [age-encryption.org/v1](https://age-encryption.org/v1). age was designed by [@benjojo](https://github.com/benjojo) and [@FiloSottile](https://github.com/FiloSottile).
|
||||
|
||||
🦀 An alternative interoperable Rust implementation is available at [github.com/str4d/rage](https://github.com/str4d/rage).
|
||||
|
||||
🌍 [Typage](https://github.com/FiloSottile/typage) is a TypeScript implementation. It works in the browser, Node.js, Deno, and Bun.
|
||||
|
||||
🔑 Hardware PIV tokens such as YubiKeys are supported through the [age-plugin-yubikey](https://github.com/str4d/age-plugin-yubikey) plugin.
|
||||
|
||||
✨ For more plugins, implementations, tools, and integrations, check out the [awesome age](https://github.com/FiloSottile/awesome-age) list.
|
||||
|
||||
💬 The author pronounces it `[aɡe̞]` [with a hard *g*](https://translate.google.com/?sl=it&text=aghe), like GIF, and is always spelled lowercase.
|
||||
💬 The author pronounces it `[aɡe̞]` [with a hard *g*](https://translate.google.com/?sl=it&text=aghe), like GIF, and it's always spelled lowercase.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -48,6 +48,12 @@ $ age --decrypt -i key.txt data.tar.gz.age > data.tar.gz
|
||||
<code>port install age</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Windows</td>
|
||||
<td>
|
||||
<code>winget install --id FiloSottile.age</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Alpine Linux v3.15+</td>
|
||||
<td>
|
||||
@@ -85,6 +91,12 @@ $ age --decrypt -i key.txt data.tar.gz.age > data.tar.gz
|
||||
<code>emerge app-crypt/age</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Guix System</td>
|
||||
<td>
|
||||
<code>guix package -i age</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>NixOS / Nix</td>
|
||||
<td>
|
||||
@@ -133,22 +145,18 @@ $ age --decrypt -i key.txt data.tar.gz.age > data.tar.gz
|
||||
<code>scoop bucket add extras && scoop install age</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>pkgx</td>
|
||||
<td>
|
||||
<code>pkgx install age</code>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
On Windows, Linux, macOS, and FreeBSD you can use the pre-built binaries.
|
||||
|
||||
```
|
||||
https://dl.filippo.io/age/latest?for=linux/amd64
|
||||
https://dl.filippo.io/age/v1.1.1?for=darwin/arm64
|
||||
https://dl.filippo.io/age/v1.3.0?for=darwin/arm64
|
||||
...
|
||||
```
|
||||
|
||||
If you download the pre-built binaries, you can check their [Sigsum proofs](./SIGSUM.md).
|
||||
|
||||
If your system has [a supported version of Go](https://go.dev/dl/), you can build from source.
|
||||
|
||||
```
|
||||
@@ -157,39 +165,6 @@ go install filippo.io/age/cmd/...@latest
|
||||
|
||||
Help from new packagers is very welcome.
|
||||
|
||||
### Verifying the release signatures
|
||||
|
||||
If you download the pre-built binaries, you can check their
|
||||
[Sigsum](https://www.sigsum.org) proofs, which are like signatures with extra
|
||||
transparency: you can cryptographically verify that every proof is logged in a
|
||||
public append-only log, so you can hold the age project accountable for every
|
||||
binary release we ever produced. This is similar to what the [Go Checksum
|
||||
Database](https://go.dev/blog/module-mirror-launch) provides.
|
||||
|
||||
```
|
||||
cat << EOF > age-sigsum-key.pub
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIM1WpnEswJLPzvXJDiswowy48U+G+G1kmgwUE2eaRHZG
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAz2WM5CyPLqiNjk7CLl4roDXwKhQ0QExXLebukZEZFS
|
||||
EOF
|
||||
cat << EOF > sigsum-trust-policy.txt
|
||||
log 154f49976b59ff09a123675f58cb3e346e0455753c3c3b15d465dcb4f6512b0b https://poc.sigsum.org/jellyfish
|
||||
witness poc.sigsum.org/nisse 1c25f8a44c635457e2e391d1efbca7d4c2951a0aef06225a881e46b98962ac6c
|
||||
witness rgdd.se/poc-witness 28c92a5a3a054d317c86fc2eeb6a7ab2054d6217100d0be67ded5b74323c5806
|
||||
group demo-quorum-rule all poc.sigsum.org/nisse rgdd.se/poc-witness
|
||||
quorum demo-quorum-rule
|
||||
EOF
|
||||
|
||||
curl -JLO "https://dl.filippo.io/age/v1.2.0?for=darwin/arm64"
|
||||
curl -JLO "https://dl.filippo.io/age/v1.2.0?for=darwin/arm64&proof"
|
||||
|
||||
go install sigsum.org/sigsum-go/cmd/[email protected]
|
||||
sigsum-verify -k age-sigsum-key.pub -p sigsum-trust-policy.txt \
|
||||
age-v1.2.0-darwin-arm64.tar.gz.proof < age-v1.2.0-darwin-arm64.tar.gz
|
||||
```
|
||||
|
||||
You can learn more about what's happening above in the [Sigsum
|
||||
docs](https://www.sigsum.org/getting-started/).
|
||||
|
||||
## Usage
|
||||
|
||||
For the full documentation, read [the age(1) man page](https://filippo.io/age/age.1).
|
||||
@@ -254,6 +229,28 @@ $ age -R recipients.txt example.jpg > example.jpg.age
|
||||
|
||||
If the argument to `-R` (or `-i`) is `-`, the file is read from standard input.
|
||||
|
||||
### Post-quantum keys
|
||||
|
||||
To generate hybrid post-quantum keys, which are secure against future quantum
|
||||
computer attacks, use the `-pq` flag with `age-keygen`. This may become the
|
||||
default in the future.
|
||||
|
||||
Post-quantum identities start with `AGE-SECRET-KEY-PQ-1...` and recipients with
|
||||
`age1pq1...`. The recipients are unfortunately ~2000 characters long.
|
||||
|
||||
```
|
||||
$ age-keygen -pq -o key.txt
|
||||
$ age-keygen -y key.txt > recipient.txt
|
||||
$ age -R recipient.txt example.jpg > example.jpg.age
|
||||
$ age -d -i key.txt example.jpg.age > example.jpg
|
||||
```
|
||||
|
||||
Support for post-quantum keys is built into age v1.3.0 and later. Alternatively,
|
||||
the `age-plugin-pq` binary can be installed and placed in `$PATH` to add support
|
||||
to any version and implementation of age that supports plugins. Recipients will
|
||||
work out of the box, while identities will have to be converted to plugin
|
||||
identities with `age-plugin-pq -identity`.
|
||||
|
||||
### Passphrases
|
||||
|
||||
Files can be encrypted with a passphrase by using `-p/--passphrase`. By default age will automatically generate a secure passphrase. Passphrase protected files are automatically detected at decrypt time.
|
||||
@@ -302,3 +299,28 @@ $ curl https://github.com/benjojo.keys | age -R - example.jpg > example.jpg.age
|
||||
```
|
||||
|
||||
Keep in mind that people might not protect SSH keys long-term, since they are revokable when used only for authentication, and that SSH keys held on YubiKeys can't be used to decrypt files.
|
||||
|
||||
### Inspecting encrypted files
|
||||
|
||||
The `age-inspect` command can display metadata about an encrypted file without decrypting it, including the recipient types, whether it uses post-quantum encryption, and the payload size.
|
||||
|
||||
```
|
||||
$ age-inspect secrets.age
|
||||
secrets.age is an age file, version "age-encryption.org/v1".
|
||||
|
||||
This file is encrypted to the following recipient types:
|
||||
- "mlkem768x25519"
|
||||
|
||||
This file uses post-quantum encryption.
|
||||
|
||||
Size breakdown (assuming it decrypts successfully):
|
||||
|
||||
Header 1627 bytes
|
||||
Encryption overhead 32 bytes
|
||||
Payload 42 bytes
|
||||
-------------------
|
||||
Total 1701 bytes
|
||||
|
||||
```
|
||||
|
||||
For scripting, use `--json` to get machine-readable output.
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
If you download the pre-built binaries of version v1.2.0+, you can check their
|
||||
[Sigsum](https://www.sigsum.org) proofs, which are like signatures with extra
|
||||
transparency: you can cryptographically verify that every proof is logged in a
|
||||
public append-only log, so the age project can be held accountable for every
|
||||
binary release we ever produced. This is similar to what the [Go Checksum
|
||||
Database](https://go.dev/blog/module-mirror-launch) provides.
|
||||
|
||||
```
|
||||
cat << EOF > age-sigsum-key.pub
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIM1WpnEswJLPzvXJDiswowy48U+G+G1kmgwUE2eaRHZG
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAz2WM5CyPLqiNjk7CLl4roDXwKhQ0QExXLebukZEZFS
|
||||
EOF
|
||||
|
||||
curl -JLO "https://dl.filippo.io/age/v1.3.0?for=darwin/arm64"
|
||||
curl -JLO "https://dl.filippo.io/age/v1.3.0?for=darwin/arm64&proof"
|
||||
|
||||
go install sigsum.org/sigsum-go/cmd/[email protected]
|
||||
sigsum-verify -k age-sigsum-key.pub -P sigsum-generic-2025-1 \
|
||||
age-v1.3.0-darwin-arm64.tar.gz.proof < age-v1.3.0-darwin-arm64.tar.gz
|
||||
```
|
||||
|
||||
You can learn more about what's happening above in the [Sigsum
|
||||
docs](https://www.sigsum.org/getting-started/).
|
||||
+256
-69
@@ -5,10 +5,10 @@
|
||||
// Package age implements file encryption according to the age-encryption.org/v1
|
||||
// specification.
|
||||
//
|
||||
// For most use cases, use the Encrypt and Decrypt functions with
|
||||
// X25519Recipient and X25519Identity. If passphrase encryption is required, use
|
||||
// ScryptRecipient and ScryptIdentity. For compatibility with existing SSH keys
|
||||
// use the filippo.io/age/agessh package.
|
||||
// For most use cases, use the [Encrypt] and [Decrypt] functions with
|
||||
// [HybridRecipient] and [HybridIdentity]. If passphrase encryption is
|
||||
// required, use [ScryptRecipient] and [ScryptIdentity]. For compatibility with
|
||||
// existing SSH keys use the filippo.io/age/agessh package.
|
||||
//
|
||||
// age encrypted files are binary and not malleable. For encoding them as text,
|
||||
// use the filippo.io/age/armor package.
|
||||
@@ -26,13 +26,13 @@
|
||||
// There is no default path for age keys. Instead, they should be stored at
|
||||
// application-specific paths. The CLI supports files where private keys are
|
||||
// listed one per line, ignoring empty lines and lines starting with "#". These
|
||||
// files can be parsed with ParseIdentities.
|
||||
// files can be parsed with [ParseIdentities].
|
||||
//
|
||||
// When integrating age into a new system, it's recommended that you only
|
||||
// support X25519 keys, and not SSH keys. The latter are supported for manual
|
||||
// encryption operations. If you need to tie into existing key management
|
||||
// infrastructure, you might want to consider implementing your own Recipient
|
||||
// and Identity.
|
||||
// support native (X25519 and hybrid) keys, and not SSH keys. The latter are
|
||||
// supported for manual encryption operations. If you need to tie into existing
|
||||
// key management infrastructure, you might want to consider implementing your
|
||||
// own [Recipient] and [Identity].
|
||||
//
|
||||
// # Backwards compatibility
|
||||
//
|
||||
@@ -46,47 +46,47 @@
|
||||
package age
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"sort"
|
||||
|
||||
"filippo.io/age/internal/format"
|
||||
"filippo.io/age/internal/stream"
|
||||
)
|
||||
|
||||
// An Identity is passed to Decrypt to unwrap an opaque file key from a
|
||||
// recipient stanza. It can be for example a secret key like X25519Identity, a
|
||||
// An Identity is passed to [Decrypt] to unwrap an opaque file key from a
|
||||
// recipient stanza. It can be for example a secret key like [HybridIdentity], a
|
||||
// plugin, or a custom implementation.
|
||||
//
|
||||
// Unwrap must return an error wrapping ErrIncorrectIdentity if none of the
|
||||
// recipient stanzas match the identity, any other error will be considered
|
||||
// fatal.
|
||||
//
|
||||
// Most age API users won't need to interact with this directly, and should
|
||||
// instead pass Recipient implementations to Encrypt and Identity
|
||||
// implementations to Decrypt.
|
||||
type Identity interface {
|
||||
// Unwrap must return an error wrapping [ErrIncorrectIdentity] if none of
|
||||
// the recipient stanzas match the identity, any other error will be
|
||||
// considered fatal.
|
||||
//
|
||||
// Most age API users won't need to interact with this method directly, and
|
||||
// should instead pass [Identity] implementations to [Decrypt].
|
||||
Unwrap(stanzas []*Stanza) (fileKey []byte, err error)
|
||||
}
|
||||
|
||||
// ErrIncorrectIdentity is returned by [Identity.Unwrap] if none of the
|
||||
// recipient stanzas match the identity.
|
||||
var ErrIncorrectIdentity = errors.New("incorrect identity for recipient block")
|
||||
|
||||
// A Recipient is passed to Encrypt to wrap an opaque file key to one or more
|
||||
// recipient stanza(s). It can be for example a public key like X25519Recipient,
|
||||
// A Recipient is passed to [Encrypt] to wrap an opaque file key to one or more
|
||||
// recipient stanza(s). It can be for example a public key like [HybridRecipient],
|
||||
// a plugin, or a custom implementation.
|
||||
//
|
||||
// Most age API users won't need to interact with this directly, and should
|
||||
// instead pass Recipient implementations to Encrypt and Identity
|
||||
// implementations to Decrypt.
|
||||
type Recipient interface {
|
||||
// Most age API users won't need to interact with this method directly, and
|
||||
// should instead pass [Recipient] implementations to [Encrypt].
|
||||
Wrap(fileKey []byte) ([]*Stanza, error)
|
||||
}
|
||||
|
||||
// RecipientWithLabels can be optionally implemented by a Recipient, in which
|
||||
// case Encrypt will use WrapWithLabels instead of Wrap.
|
||||
// RecipientWithLabels can be optionally implemented by a [Recipient], in which
|
||||
// case [Encrypt] will use WrapWithLabels instead of [Recipient.Wrap].
|
||||
//
|
||||
// Encrypt will succeed only if the labels returned by all the recipients
|
||||
// (assuming the empty set for those that don't implement RecipientWithLabels)
|
||||
@@ -103,9 +103,9 @@ type RecipientWithLabels interface {
|
||||
// A Stanza is a section of the age header that encapsulates the file key as
|
||||
// encrypted to a specific recipient.
|
||||
//
|
||||
// Most age API users won't need to interact with this directly, and should
|
||||
// instead pass Recipient implementations to Encrypt and Identity
|
||||
// implementations to Decrypt.
|
||||
// Most age API users won't need to interact with this type directly, and should
|
||||
// instead pass [Recipient] implementations to [Encrypt] and [Identity]
|
||||
// implementations to [Decrypt].
|
||||
type Stanza struct {
|
||||
Type string
|
||||
Args []string
|
||||
@@ -115,35 +115,23 @@ type Stanza struct {
|
||||
const fileKeySize = 16
|
||||
const streamNonceSize = 16
|
||||
|
||||
// Encrypt encrypts a file to one or more recipients.
|
||||
//
|
||||
// Writes to the returned WriteCloser are encrypted and written to dst as an age
|
||||
// file. Every recipient will be able to decrypt the file.
|
||||
//
|
||||
// The caller must call Close on the WriteCloser when done for the last chunk to
|
||||
// be encrypted and flushed to dst.
|
||||
func Encrypt(dst io.Writer, recipients ...Recipient) (io.WriteCloser, error) {
|
||||
func encryptHdr(fileKey []byte, recipients ...Recipient) (*format.Header, error) {
|
||||
if len(recipients) == 0 {
|
||||
return nil, errors.New("no recipients specified")
|
||||
}
|
||||
|
||||
fileKey := make([]byte, fileKeySize)
|
||||
if _, err := rand.Read(fileKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hdr := &format.Header{}
|
||||
var labels []string
|
||||
for i, r := range recipients {
|
||||
stanzas, l, err := wrapWithLabels(r, fileKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to wrap key for recipient #%d: %v", i, err)
|
||||
return nil, fmt.Errorf("failed to wrap key for recipient #%d: %w", i, err)
|
||||
}
|
||||
sort.Strings(l)
|
||||
if i == 0 {
|
||||
labels = l
|
||||
} else if !slicesEqual(labels, l) {
|
||||
return nil, fmt.Errorf("incompatible recipients")
|
||||
return nil, incompatibleLabelsError(labels, l)
|
||||
}
|
||||
for _, s := range stanzas {
|
||||
hdr.Recipients = append(hdr.Recipients, (*format.Stanza)(s))
|
||||
@@ -154,19 +142,62 @@ func Encrypt(dst io.Writer, recipients ...Recipient) (io.WriteCloser, error) {
|
||||
} else {
|
||||
hdr.MAC = mac
|
||||
}
|
||||
return hdr, nil
|
||||
}
|
||||
|
||||
// Encrypt encrypts a file to one or more recipients. Every recipient will be
|
||||
// able to decrypt the file.
|
||||
//
|
||||
// Writes to the returned WriteCloser are encrypted and written to dst as an age
|
||||
// file. The caller must call Close on the WriteCloser when done for the last
|
||||
// chunk to be encrypted and flushed to dst.
|
||||
func Encrypt(dst io.Writer, recipients ...Recipient) (io.WriteCloser, error) {
|
||||
fileKey := make([]byte, fileKeySize)
|
||||
rand.Read(fileKey)
|
||||
|
||||
hdr, err := encryptHdr(fileKey, recipients...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := hdr.Marshal(dst); err != nil {
|
||||
return nil, fmt.Errorf("failed to write header: %v", err)
|
||||
return nil, fmt.Errorf("failed to write header: %w", err)
|
||||
}
|
||||
|
||||
nonce := make([]byte, streamNonceSize)
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rand.Read(nonce)
|
||||
if _, err := dst.Write(nonce); err != nil {
|
||||
return nil, fmt.Errorf("failed to write nonce: %v", err)
|
||||
return nil, fmt.Errorf("failed to write nonce: %w", err)
|
||||
}
|
||||
|
||||
return stream.NewWriter(streamKey(fileKey, nonce), dst)
|
||||
return stream.NewEncryptWriter(streamKey(fileKey, nonce), dst)
|
||||
}
|
||||
|
||||
// EncryptReader encrypts a file to one or more recipients. Every recipient will be
|
||||
// able to decrypt the file.
|
||||
//
|
||||
// Reads from the returned Reader produce the encrypted file, where the plaintext
|
||||
// is read from src.
|
||||
func EncryptReader(src io.Reader, recipients ...Recipient) (io.Reader, error) {
|
||||
fileKey := make([]byte, fileKeySize)
|
||||
rand.Read(fileKey)
|
||||
|
||||
hdr, err := encryptHdr(fileKey, recipients...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buf := &bytes.Buffer{}
|
||||
if err := hdr.Marshal(buf); err != nil {
|
||||
return nil, fmt.Errorf("failed to prepare header: %w", err)
|
||||
}
|
||||
|
||||
nonce := make([]byte, streamNonceSize)
|
||||
rand.Read(nonce)
|
||||
|
||||
r, err := stream.NewEncryptReader(streamKey(fileKey, nonce), src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return io.MultiReader(buf, bytes.NewReader(nonce), r), nil
|
||||
}
|
||||
|
||||
func wrapWithLabels(r Recipient, fileKey []byte) (s []*Stanza, labels []string, err error) {
|
||||
@@ -189,39 +220,147 @@ func slicesEqual(s1, s2 []string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// NoIdentityMatchError is returned by Decrypt when none of the supplied
|
||||
func incompatibleLabelsError(l1, l2 []string) error {
|
||||
hasPQ1 := slices.Contains(l1, "postquantum")
|
||||
hasPQ2 := slices.Contains(l2, "postquantum")
|
||||
if hasPQ1 != hasPQ2 {
|
||||
return fmt.Errorf("incompatible recipients: can't mix post-quantum and classic recipients, or the file would be vulnerable to quantum computers")
|
||||
}
|
||||
return fmt.Errorf("incompatible recipients: %q and %q can't be mixed", l1, l2)
|
||||
}
|
||||
|
||||
// NoIdentityMatchError is returned by [Decrypt] when none of the supplied
|
||||
// identities match the encrypted file.
|
||||
type NoIdentityMatchError struct {
|
||||
// Errors is a slice of all the errors returned to Decrypt by the Unwrap
|
||||
// calls it made. They all wrap ErrIncorrectIdentity.
|
||||
// calls it made. They all wrap [ErrIncorrectIdentity].
|
||||
Errors []error
|
||||
// StanzaTypes are the first argument of each recipient stanza in the
|
||||
// encrypted file's header.
|
||||
StanzaTypes []string
|
||||
}
|
||||
|
||||
func (*NoIdentityMatchError) Error() string {
|
||||
func (e *NoIdentityMatchError) Error() string {
|
||||
if len(e.Errors) == 1 {
|
||||
return "identity did not match any of the recipients: " + e.Errors[0].Error()
|
||||
}
|
||||
return "no identity matched any of the recipients"
|
||||
}
|
||||
|
||||
// Decrypt decrypts a file encrypted to one or more identities.
|
||||
//
|
||||
// It returns a Reader reading the decrypted plaintext of the age file read
|
||||
// from src. All identities will be tried until one successfully decrypts the file.
|
||||
func Decrypt(src io.Reader, identities ...Identity) (io.Reader, error) {
|
||||
if len(identities) == 0 {
|
||||
return nil, errors.New("no identities specified")
|
||||
}
|
||||
func (e *NoIdentityMatchError) Unwrap() []error {
|
||||
return e.Errors
|
||||
}
|
||||
|
||||
// Decrypt decrypts a file encrypted to one or more identities.
|
||||
// All identities will be tried until one successfully decrypts the file.
|
||||
// Native, non-interactive identities are tried before any other identities.
|
||||
//
|
||||
// Decrypt returns a Reader reading the decrypted plaintext of the age file read
|
||||
// from src. If no identity matches the encrypted file, the returned error will
|
||||
// be of type [NoIdentityMatchError].
|
||||
func Decrypt(src io.Reader, identities ...Identity) (io.Reader, error) {
|
||||
hdr, payload, err := format.Parse(src)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read header: %w", err)
|
||||
}
|
||||
|
||||
fileKey, err := decryptHdr(hdr, identities...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nonce := make([]byte, streamNonceSize)
|
||||
if _, err := io.ReadFull(payload, nonce); err != nil {
|
||||
return nil, fmt.Errorf("failed to read nonce: %w", err)
|
||||
}
|
||||
|
||||
return stream.NewDecryptReader(streamKey(fileKey, nonce), payload)
|
||||
}
|
||||
|
||||
// DecryptReaderAt decrypts a file encrypted to one or more identities.
|
||||
// All identities will be tried until one successfully decrypts the file.
|
||||
// Native, non-interactive identities are tried before any other identities.
|
||||
//
|
||||
// DecryptReaderAt takes an underlying [io.ReaderAt] and its total encrypted
|
||||
// size, and returns a ReaderAt of the decrypted plaintext and the plaintext
|
||||
// size. These can be used for example to instantiate an [io.SectionReader],
|
||||
// which implements [io.Reader] and [io.Seeker], or for [zip.NewReader].
|
||||
// Note that ReaderAt by definition disregards the seek position of src.
|
||||
//
|
||||
// The ReadAt method of the returned ReaderAt can be called concurrently.
|
||||
// The ReaderAt will internally cache the most recently decrypted chunk.
|
||||
// DecryptReaderAt reads and decrypts the final chunk before returning,
|
||||
// to authenticate the plaintext size.
|
||||
//
|
||||
// If no identity matches the encrypted file, the returned error will be of
|
||||
// type [NoIdentityMatchError].
|
||||
func DecryptReaderAt(src io.ReaderAt, encryptedSize int64, identities ...Identity) (io.ReaderAt, int64, error) {
|
||||
srcReader := io.NewSectionReader(src, 0, encryptedSize)
|
||||
hdr, payload, err := format.Parse(srcReader)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to read header: %w", err)
|
||||
}
|
||||
buf := &bytes.Buffer{}
|
||||
if err := hdr.Marshal(buf); err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to serialize header: %w", err)
|
||||
}
|
||||
|
||||
fileKey, err := decryptHdr(hdr, identities...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
nonce := make([]byte, streamNonceSize)
|
||||
if _, err := io.ReadFull(payload, nonce); err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to read nonce: %w", err)
|
||||
}
|
||||
|
||||
payloadOffset := int64(buf.Len()) + int64(len(nonce))
|
||||
payloadSize := encryptedSize - payloadOffset
|
||||
plaintextSize, err := stream.PlaintextSize(payloadSize)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
payloadReaderAt := io.NewSectionReader(src, payloadOffset, payloadSize)
|
||||
r, err := stream.NewDecryptReaderAt(streamKey(fileKey, nonce), payloadReaderAt, payloadSize)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return r, plaintextSize, nil
|
||||
}
|
||||
|
||||
func decryptHdr(hdr *format.Header, identities ...Identity) ([]byte, error) {
|
||||
if len(identities) == 0 {
|
||||
return nil, errors.New("no identities specified")
|
||||
}
|
||||
slices.SortStableFunc(identities, func(a, b Identity) int {
|
||||
var aIsNative, bIsNative bool
|
||||
switch a.(type) {
|
||||
case *X25519Identity, *HybridIdentity, *ScryptIdentity:
|
||||
aIsNative = true
|
||||
}
|
||||
switch b.(type) {
|
||||
case *X25519Identity, *HybridIdentity, *ScryptIdentity:
|
||||
bIsNative = true
|
||||
}
|
||||
if aIsNative && !bIsNative {
|
||||
return -1
|
||||
}
|
||||
if !aIsNative && bIsNative {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
stanzas := make([]*Stanza, 0, len(hdr.Recipients))
|
||||
errNoMatch := &NoIdentityMatchError{}
|
||||
for _, s := range hdr.Recipients {
|
||||
errNoMatch.StanzaTypes = append(errNoMatch.StanzaTypes, s.Type)
|
||||
stanzas = append(stanzas, (*Stanza)(s))
|
||||
}
|
||||
errNoMatch := &NoIdentityMatchError{}
|
||||
var fileKey []byte
|
||||
for _, id := range identities {
|
||||
var err error
|
||||
fileKey, err = id.Unwrap(stanzas)
|
||||
if errors.Is(err, ErrIncorrectIdentity) {
|
||||
errNoMatch.Errors = append(errNoMatch.Errors, err)
|
||||
@@ -243,12 +382,7 @@ func Decrypt(src io.Reader, identities ...Identity) (io.Reader, error) {
|
||||
return nil, errors.New("bad header MAC")
|
||||
}
|
||||
|
||||
nonce := make([]byte, streamNonceSize)
|
||||
if _, err := io.ReadFull(payload, nonce); err != nil {
|
||||
return nil, fmt.Errorf("failed to read nonce: %w", err)
|
||||
}
|
||||
|
||||
return stream.NewReader(streamKey(fileKey, nonce), payload)
|
||||
return fileKey, nil
|
||||
}
|
||||
|
||||
// multiUnwrap is a helper that implements Identity.Unwrap in terms of a
|
||||
@@ -269,3 +403,56 @@ func multiUnwrap(unwrap func(*Stanza) ([]byte, error), stanzas []*Stanza) ([]byt
|
||||
}
|
||||
return nil, ErrIncorrectIdentity
|
||||
}
|
||||
|
||||
// ExtractHeader returns a detached header from the src file.
|
||||
//
|
||||
// The detached header can be decrypted with [DecryptHeader] (for example on a
|
||||
// different system, without sharing the ciphertext) and then the file key can
|
||||
// be used with [NewInjectedFileKeyIdentity].
|
||||
//
|
||||
// This is a low-level function that most users won't need.
|
||||
func ExtractHeader(src io.Reader) ([]byte, error) {
|
||||
hdr, _, err := format.Parse(src)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read header: %w", err)
|
||||
}
|
||||
buf := &bytes.Buffer{}
|
||||
if err := hdr.Marshal(buf); err != nil {
|
||||
return nil, fmt.Errorf("failed to serialize header: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// DecryptHeader decrypts a detached header and returns a file key.
|
||||
//
|
||||
// The detached header can be produced by [ExtractHeader], and the
|
||||
// returned file key can be used with [NewInjectedFileKeyIdentity].
|
||||
//
|
||||
// This is a low-level function that most users won't need.
|
||||
// It is the caller's responsibility to keep track of what file the
|
||||
// returned file key decrypts, and to ensure the file key is not used
|
||||
// for any other purpose.
|
||||
func DecryptHeader(header []byte, identities ...Identity) ([]byte, error) {
|
||||
hdr, _, err := format.Parse(bytes.NewReader(header))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read header: %w", err)
|
||||
}
|
||||
return decryptHdr(hdr, identities...)
|
||||
}
|
||||
|
||||
type injectedFileKeyIdentity struct {
|
||||
fileKey []byte
|
||||
}
|
||||
|
||||
// NewInjectedFileKeyIdentity returns an [Identity] that always produces
|
||||
// a fixed file key, allowing the use of a file key obtained out-of-band,
|
||||
// for example via [DecryptHeader].
|
||||
//
|
||||
// This is a low-level function that most users won't need.
|
||||
func NewInjectedFileKeyIdentity(fileKey []byte) Identity {
|
||||
return injectedFileKeyIdentity{fileKey}
|
||||
}
|
||||
|
||||
func (i injectedFileKeyIdentity) Unwrap(stanzas []*Stanza) (fileKey []byte, err error) {
|
||||
return i.fileKey, nil
|
||||
}
|
||||
+1
-1
@@ -37,7 +37,7 @@ func polymod(values []byte) uint32 {
|
||||
top := chk >> 25
|
||||
chk = (chk & 0x1ffffff) << 5
|
||||
chk = chk ^ uint32(v)
|
||||
for i := 0; i < 5; i++ {
|
||||
for i := range 5 {
|
||||
bit := top >> i & 1
|
||||
if bit == 1 {
|
||||
chk ^= generator[i]
|
||||
|
||||
+5
-6
@@ -77,10 +77,7 @@ func (w *WrappedBase64Encoder) writeWrapped(p []byte) (int, error) {
|
||||
panic("age: internal error: non-empty WrappedBase64Encoder.buf")
|
||||
}
|
||||
for len(p) > 0 {
|
||||
toWrite := ColumnsPerLine - (w.written % ColumnsPerLine)
|
||||
if toWrite > len(p) {
|
||||
toWrite = len(p)
|
||||
}
|
||||
toWrite := min(ColumnsPerLine-(w.written%ColumnsPerLine), len(p))
|
||||
n, _ := w.buf.Write(p[:toWrite])
|
||||
w.written += n
|
||||
p = p[n:]
|
||||
@@ -228,7 +225,7 @@ func (e *ParseError) Unwrap() error {
|
||||
return e.err
|
||||
}
|
||||
|
||||
func errorf(format string, a ...interface{}) error {
|
||||
func errorf(format string, a ...any) error {
|
||||
return &ParseError{fmt.Errorf(format, a...)}
|
||||
}
|
||||
|
||||
@@ -239,7 +236,9 @@ func Parse(input io.Reader) (*Header, io.Reader, error) {
|
||||
rr := bufio.NewReader(input)
|
||||
|
||||
line, err := rr.ReadString('\n')
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
return nil, nil, errorf("file is empty")
|
||||
} else if err != nil {
|
||||
return nil, nil, errorf("failed to read intro: %w", err)
|
||||
}
|
||||
if line != intro {
|
||||
|
||||
+260
-39
@@ -6,17 +6,45 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/cipher"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync/atomic"
|
||||
|
||||
"golang.org/x/crypto/chacha20poly1305"
|
||||
)
|
||||
|
||||
const ChunkSize = 64 * 1024
|
||||
|
||||
type Reader struct {
|
||||
func EncryptedChunkCount(encryptedSize int64) (int64, error) {
|
||||
chunks := (encryptedSize + encChunkSize - 1) / encChunkSize
|
||||
|
||||
plaintextSize := encryptedSize - chunks*chacha20poly1305.Overhead
|
||||
expChunks := (plaintextSize + ChunkSize - 1) / ChunkSize
|
||||
// Empty plaintext, the only case that allows (and requires) an empty chunk.
|
||||
if plaintextSize == 0 {
|
||||
expChunks = 1
|
||||
}
|
||||
if expChunks != chunks {
|
||||
return 0, fmt.Errorf("invalid encrypted payload size: %d", encryptedSize)
|
||||
}
|
||||
|
||||
return chunks, nil
|
||||
}
|
||||
|
||||
func PlaintextSize(encryptedSize int64) (int64, error) {
|
||||
chunks, err := EncryptedChunkCount(encryptedSize)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
plaintextSize := encryptedSize - chunks*chacha20poly1305.Overhead
|
||||
return plaintextSize, nil
|
||||
}
|
||||
|
||||
type DecryptReader struct {
|
||||
a cipher.AEAD
|
||||
src io.Reader
|
||||
|
||||
@@ -32,18 +60,15 @@ const (
|
||||
lastChunkFlag = 0x01
|
||||
)
|
||||
|
||||
func NewReader(key []byte, src io.Reader) (*Reader, error) {
|
||||
func NewDecryptReader(key []byte, src io.Reader) (*DecryptReader, error) {
|
||||
aead, err := chacha20poly1305.New(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Reader{
|
||||
a: aead,
|
||||
src: src,
|
||||
}, nil
|
||||
return &DecryptReader{a: aead, src: src}, nil
|
||||
}
|
||||
|
||||
func (r *Reader) Read(p []byte) (int, error) {
|
||||
func (r *DecryptReader) Read(p []byte) (int, error) {
|
||||
if len(r.unread) > 0 {
|
||||
n := copy(p, r.unread)
|
||||
r.unread = r.unread[n:]
|
||||
@@ -85,7 +110,7 @@ func (r *Reader) Read(p []byte) (int, error) {
|
||||
// readChunk reads the next chunk of ciphertext from r.src and makes it available
|
||||
// in r.unread. last is true if the chunk was marked as the end of the message.
|
||||
// readChunk must not be called again after returning a last chunk or an error.
|
||||
func (r *Reader) readChunk() (last bool, err error) {
|
||||
func (r *DecryptReader) readChunk() (last bool, err error) {
|
||||
if len(r.unread) != 0 {
|
||||
panic("stream: internal error: readChunk called with dirty buffer")
|
||||
}
|
||||
@@ -118,7 +143,7 @@ func (r *Reader) readChunk() (last bool, err error) {
|
||||
out, err = r.a.Open(outBuf, r.nonce[:], in, nil)
|
||||
}
|
||||
if err != nil {
|
||||
return false, errors.New("failed to decrypt and authenticate payload chunk")
|
||||
return false, errors.New("failed to decrypt and authenticate payload chunk, file may be corrupted or tampered with")
|
||||
}
|
||||
|
||||
incNonce(&r.nonce)
|
||||
@@ -130,12 +155,17 @@ func incNonce(nonce *[chacha20poly1305.NonceSize]byte) {
|
||||
for i := len(nonce) - 2; i >= 0; i-- {
|
||||
nonce[i]++
|
||||
if nonce[i] != 0 {
|
||||
break
|
||||
} else if i == 0 {
|
||||
// The counter is 88 bits, this is unreachable.
|
||||
panic("stream: chunk counter wrapped around")
|
||||
return
|
||||
}
|
||||
}
|
||||
// The counter is 88 bits, this is unreachable.
|
||||
panic("stream: chunk counter wrapped around")
|
||||
}
|
||||
|
||||
func nonceForChunk(chunkIndex int64) *[chacha20poly1305.NonceSize]byte {
|
||||
var nonce [chacha20poly1305.NonceSize]byte
|
||||
binary.BigEndian.PutUint64(nonce[3:11], uint64(chunkIndex))
|
||||
return &nonce
|
||||
}
|
||||
|
||||
func setLastChunkFlag(nonce *[chacha20poly1305.NonceSize]byte) {
|
||||
@@ -146,30 +176,23 @@ func nonceIsZero(nonce *[chacha20poly1305.NonceSize]byte) bool {
|
||||
return *nonce == [chacha20poly1305.NonceSize]byte{}
|
||||
}
|
||||
|
||||
type Writer struct {
|
||||
a cipher.AEAD
|
||||
dst io.Writer
|
||||
unwritten []byte // backed by buf
|
||||
buf [encChunkSize]byte
|
||||
nonce [chacha20poly1305.NonceSize]byte
|
||||
err error
|
||||
type EncryptWriter struct {
|
||||
a cipher.AEAD
|
||||
dst io.Writer
|
||||
buf bytes.Buffer
|
||||
nonce [chacha20poly1305.NonceSize]byte
|
||||
err error
|
||||
}
|
||||
|
||||
func NewWriter(key []byte, dst io.Writer) (*Writer, error) {
|
||||
func NewEncryptWriter(key []byte, dst io.Writer) (*EncryptWriter, error) {
|
||||
aead, err := chacha20poly1305.New(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w := &Writer{
|
||||
a: aead,
|
||||
dst: dst,
|
||||
}
|
||||
w.unwritten = w.buf[:0]
|
||||
return w, nil
|
||||
return &EncryptWriter{a: aead, dst: dst}, nil
|
||||
}
|
||||
|
||||
func (w *Writer) Write(p []byte) (n int, err error) {
|
||||
// TODO: consider refactoring with a bytes.Buffer.
|
||||
func (w *EncryptWriter) Write(p []byte) (n int, err error) {
|
||||
if w.err != nil {
|
||||
return 0, w.err
|
||||
}
|
||||
@@ -179,12 +202,13 @@ func (w *Writer) Write(p []byte) (n int, err error) {
|
||||
|
||||
total := len(p)
|
||||
for len(p) > 0 {
|
||||
freeBuf := w.buf[len(w.unwritten):ChunkSize]
|
||||
n := copy(freeBuf, p)
|
||||
n := min(len(p), ChunkSize-w.buf.Len())
|
||||
w.buf.Write(p[:n])
|
||||
p = p[n:]
|
||||
w.unwritten = w.unwritten[:len(w.unwritten)+n]
|
||||
|
||||
if len(w.unwritten) == ChunkSize && len(p) > 0 {
|
||||
// Only flush if there's a full chunk with bytes still to write, or we
|
||||
// can't know if this is the last chunk yet.
|
||||
if w.buf.Len() == ChunkSize && len(p) > 0 {
|
||||
if err := w.flushChunk(notLastChunk); err != nil {
|
||||
w.err = err
|
||||
return 0, err
|
||||
@@ -195,7 +219,7 @@ func (w *Writer) Write(p []byte) (n int, err error) {
|
||||
}
|
||||
|
||||
// Close flushes the last chunk. It does not close the underlying Writer.
|
||||
func (w *Writer) Close() error {
|
||||
func (w *EncryptWriter) Close() error {
|
||||
if w.err != nil {
|
||||
return w.err
|
||||
}
|
||||
@@ -214,17 +238,214 @@ const (
|
||||
notLastChunk = false
|
||||
)
|
||||
|
||||
func (w *Writer) flushChunk(last bool) error {
|
||||
if !last && len(w.unwritten) != ChunkSize {
|
||||
func (w *EncryptWriter) flushChunk(last bool) error {
|
||||
if !last && w.buf.Len() != ChunkSize {
|
||||
panic("stream: internal error: flush called with partial chunk")
|
||||
}
|
||||
|
||||
if last {
|
||||
setLastChunkFlag(&w.nonce)
|
||||
}
|
||||
buf := w.a.Seal(w.buf[:0], w.nonce[:], w.unwritten, nil)
|
||||
_, err := w.dst.Write(buf)
|
||||
w.unwritten = w.buf[:0]
|
||||
w.buf.Grow(chacha20poly1305.Overhead)
|
||||
ciphertext := w.a.Seal(w.buf.Bytes()[:0], w.nonce[:], w.buf.Bytes(), nil)
|
||||
_, err := w.dst.Write(ciphertext)
|
||||
incNonce(&w.nonce)
|
||||
w.buf.Reset()
|
||||
return err
|
||||
}
|
||||
|
||||
type EncryptReader struct {
|
||||
a cipher.AEAD
|
||||
src io.Reader
|
||||
|
||||
// The first ready bytes of buf are already encrypted. This may be less than
|
||||
// buf.Len(), because we need to over-read to know if a chunk is the last.
|
||||
ready int
|
||||
buf bytes.Buffer
|
||||
|
||||
nonce [chacha20poly1305.NonceSize]byte
|
||||
err error
|
||||
}
|
||||
|
||||
func NewEncryptReader(key []byte, src io.Reader) (*EncryptReader, error) {
|
||||
aead, err := chacha20poly1305.New(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &EncryptReader{a: aead, src: src}, nil
|
||||
}
|
||||
|
||||
func (r *EncryptReader) Read(p []byte) (int, error) {
|
||||
if r.ready > 0 {
|
||||
n, err := r.buf.Read(p[:min(len(p), r.ready)])
|
||||
r.ready -= n
|
||||
return n, err
|
||||
}
|
||||
if r.err != nil {
|
||||
return 0, r.err
|
||||
}
|
||||
if len(p) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
if err := r.feedBuffer(); err != nil {
|
||||
r.err = err
|
||||
return 0, err
|
||||
}
|
||||
|
||||
n, err := r.buf.Read(p[:min(len(p), r.ready)])
|
||||
r.ready -= n
|
||||
return n, err
|
||||
}
|
||||
|
||||
// feedBuffer reads and encrypts the next chunk from r.src and appends it to
|
||||
// r.buf. It sets r.ready to the number of newly available bytes in r.buf.
|
||||
func (r *EncryptReader) feedBuffer() error {
|
||||
if r.ready > 0 {
|
||||
panic("stream: internal error: feedBuffer called with dirty buffer")
|
||||
}
|
||||
|
||||
// CopyN will use r.buf.ReadFrom/WriteTo to fill the buffer directly.
|
||||
// We need ChunkSize + 1 bytes to determine if this is the last chunk.
|
||||
_, err := io.CopyN(&r.buf, r.src, int64(ChunkSize-r.buf.Len()+1))
|
||||
if err != nil && err != io.EOF {
|
||||
return err
|
||||
}
|
||||
|
||||
if last := r.buf.Len() <= ChunkSize; last {
|
||||
setLastChunkFlag(&r.nonce)
|
||||
|
||||
// After Grow, we know r.buf.Bytes() has enough capacity for the
|
||||
// overhead. We encrypt in place and then do a Write to include the
|
||||
// overhead in the buffer.
|
||||
r.buf.Grow(chacha20poly1305.Overhead)
|
||||
plaintext := r.buf.Bytes()
|
||||
r.a.Seal(plaintext[:0], r.nonce[:], plaintext, nil)
|
||||
incNonce(&r.nonce)
|
||||
r.buf.Write(plaintext[len(plaintext) : len(plaintext)+chacha20poly1305.Overhead])
|
||||
r.ready = r.buf.Len()
|
||||
|
||||
r.err = io.EOF
|
||||
return nil
|
||||
}
|
||||
|
||||
// Same, but accounting for the tail byte which will remain unencrypted and
|
||||
// needs to be shifted past the overhead.
|
||||
if r.buf.Len() != ChunkSize+1 {
|
||||
panic("stream: internal error: unexpected buffer length")
|
||||
}
|
||||
tailByte := r.buf.Bytes()[ChunkSize]
|
||||
r.buf.Grow(chacha20poly1305.Overhead)
|
||||
plaintext := r.buf.Bytes()[:ChunkSize]
|
||||
r.a.Seal(plaintext[:0], r.nonce[:], plaintext, nil)
|
||||
incNonce(&r.nonce)
|
||||
r.buf.Write(plaintext[len(plaintext)+1 : len(plaintext)+chacha20poly1305.Overhead])
|
||||
r.buf.WriteByte(tailByte)
|
||||
r.ready = ChunkSize + chacha20poly1305.Overhead
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type DecryptReaderAt struct {
|
||||
a cipher.AEAD
|
||||
src io.ReaderAt
|
||||
size int64
|
||||
chunks int64
|
||||
cache atomic.Pointer[cachedChunk]
|
||||
}
|
||||
|
||||
type cachedChunk struct {
|
||||
off int64
|
||||
data []byte
|
||||
}
|
||||
|
||||
func NewDecryptReaderAt(key []byte, src io.ReaderAt, size int64) (*DecryptReaderAt, error) {
|
||||
aead, err := chacha20poly1305.New(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check that size is valid by decrypting the final chunk.
|
||||
chunks, err := EncryptedChunkCount(size)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
finalChunkIndex := chunks - 1
|
||||
finalChunkOff := finalChunkIndex * encChunkSize
|
||||
finalChunkSize := size - finalChunkOff
|
||||
finalChunk := make([]byte, finalChunkSize)
|
||||
if _, err := src.ReadAt(finalChunk, finalChunkOff); err != nil {
|
||||
return nil, fmt.Errorf("failed to read final chunk: %w", err)
|
||||
}
|
||||
nonce := nonceForChunk(finalChunkIndex)
|
||||
setLastChunkFlag(nonce)
|
||||
plaintext, err := aead.Open(finalChunk[:0], nonce[:], finalChunk, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt and authenticate final chunk: %w", err)
|
||||
}
|
||||
cache := &cachedChunk{off: finalChunkOff, data: plaintext}
|
||||
|
||||
plaintextSize := size - chunks*chacha20poly1305.Overhead
|
||||
r := &DecryptReaderAt{a: aead, src: src, size: plaintextSize, chunks: chunks}
|
||||
r.cache.Store(cache)
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (r *DecryptReaderAt) ReadAt(p []byte, off int64) (n int, err error) {
|
||||
if off < 0 || off > r.size {
|
||||
return 0, fmt.Errorf("offset out of range [0:%d]: %d", r.size, off)
|
||||
}
|
||||
if len(p) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var cacheUpdate *cachedChunk
|
||||
chunk := make([]byte, encChunkSize)
|
||||
for len(p) > 0 && off < r.size {
|
||||
chunkIndex := off / ChunkSize
|
||||
chunkOff := chunkIndex * encChunkSize
|
||||
encSize := r.size + r.chunks*chacha20poly1305.Overhead
|
||||
chunkSize := min(encSize-chunkOff, encChunkSize)
|
||||
|
||||
cached := r.cache.Load()
|
||||
var plaintext []byte
|
||||
if cached != nil && cached.off == chunkOff {
|
||||
plaintext = cached.data
|
||||
cacheUpdate = nil
|
||||
} else {
|
||||
nn, err := r.src.ReadAt(chunk[:chunkSize], chunkOff)
|
||||
if err == io.EOF {
|
||||
if int64(nn) != chunkSize {
|
||||
err = io.ErrUnexpectedEOF
|
||||
} else {
|
||||
err = nil
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return n, fmt.Errorf("failed to read chunk at offset %d: %w", chunkOff, err)
|
||||
}
|
||||
nonce := nonceForChunk(chunkIndex)
|
||||
if chunkIndex == r.chunks-1 {
|
||||
setLastChunkFlag(nonce)
|
||||
}
|
||||
plaintext, err = r.a.Open(chunk[:0], nonce[:], chunk[:chunkSize], nil)
|
||||
if err != nil {
|
||||
return n, fmt.Errorf("failed to decrypt and authenticate chunk at offset %d: %w", chunkOff, err)
|
||||
}
|
||||
cacheUpdate = &cachedChunk{off: chunkOff, data: plaintext}
|
||||
}
|
||||
|
||||
plainChunkOff := int(off - chunkIndex*ChunkSize)
|
||||
copySize := min(len(plaintext)-plainChunkOff, len(p))
|
||||
copy(p, plaintext[plainChunkOff:plainChunkOff+copySize])
|
||||
p = p[copySize:]
|
||||
off += int64(copySize)
|
||||
n += copySize
|
||||
}
|
||||
if cacheUpdate != nil {
|
||||
r.cache.Store(cacheUpdate)
|
||||
}
|
||||
if off == r.size {
|
||||
return n, io.EOF
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
+41
-13
@@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// ParseIdentities parses a file with one or more private key encodings, one per
|
||||
@@ -16,10 +17,10 @@ import (
|
||||
//
|
||||
// This is the same syntax as the private key files accepted by the CLI, except
|
||||
// the CLI also accepts SSH private keys, which are not recommended for the
|
||||
// average application.
|
||||
// average application, and plugins, which involve invoking external programs.
|
||||
//
|
||||
// Currently, all returned values are of type *X25519Identity, but different
|
||||
// types might be returned in the future.
|
||||
// Currently, all returned values are of type *[X25519Identity] or
|
||||
// *[HybridIdentity], but different types might be returned in the future.
|
||||
func ParseIdentities(f io.Reader) ([]Identity, error) {
|
||||
const privateKeySizeLimit = 1 << 24 // 16 MiB
|
||||
var ids []Identity
|
||||
@@ -31,30 +32,45 @@ func ParseIdentities(f io.Reader) ([]Identity, error) {
|
||||
if strings.HasPrefix(line, "#") || line == "" {
|
||||
continue
|
||||
}
|
||||
i, err := ParseX25519Identity(line)
|
||||
if !utf8.ValidString(line) {
|
||||
return nil, fmt.Errorf("identities file is not valid UTF-8")
|
||||
}
|
||||
i, err := parseIdentity(line)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error at line %d: %v", n, err)
|
||||
}
|
||||
ids = append(ids, i)
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("failed to read secret keys file: %v", err)
|
||||
return nil, fmt.Errorf("failed to read identities file: %v", err)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, fmt.Errorf("no secret keys found")
|
||||
return nil, fmt.Errorf("no identities found")
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func parseIdentity(arg string) (Identity, error) {
|
||||
switch {
|
||||
case strings.HasPrefix(arg, "AGE-SECRET-KEY-1"):
|
||||
return ParseX25519Identity(arg)
|
||||
case strings.HasPrefix(arg, "AGE-SECRET-KEY-PQ-1"):
|
||||
return ParseHybridIdentity(arg)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown identity type: %q", arg)
|
||||
}
|
||||
}
|
||||
|
||||
// ParseRecipients parses a file with one or more public key encodings, one per
|
||||
// line. Empty lines and lines starting with "#" are ignored.
|
||||
//
|
||||
// This is the same syntax as the recipients files accepted by the CLI, except
|
||||
// the CLI also accepts SSH recipients, which are not recommended for the
|
||||
// average application.
|
||||
// average application, tagged recipients, which have different privacy
|
||||
// properties, and plugins, which involve invoking external programs.
|
||||
//
|
||||
// Currently, all returned values are of type *X25519Recipient, but different
|
||||
// types might be returned in the future.
|
||||
// Currently, all returned values are of type *[X25519Recipient] or
|
||||
// *[HybridRecipient] but different types might be returned in the future.
|
||||
func ParseRecipients(f io.Reader) ([]Recipient, error) {
|
||||
const recipientFileSizeLimit = 1 << 24 // 16 MiB
|
||||
var recs []Recipient
|
||||
@@ -66,11 +82,12 @@ func ParseRecipients(f io.Reader) ([]Recipient, error) {
|
||||
if strings.HasPrefix(line, "#") || line == "" {
|
||||
continue
|
||||
}
|
||||
r, err := ParseX25519Recipient(line)
|
||||
if !utf8.ValidString(line) {
|
||||
return nil, fmt.Errorf("recipients file is not valid UTF-8")
|
||||
}
|
||||
r, err := parseRecipient(line)
|
||||
if err != nil {
|
||||
// Hide the error since it might unintentionally leak the contents
|
||||
// of confidential files.
|
||||
return nil, fmt.Errorf("malformed recipient at line %d", n)
|
||||
return nil, fmt.Errorf("error at line %d: %v", n, err)
|
||||
}
|
||||
recs = append(recs, r)
|
||||
}
|
||||
@@ -82,3 +99,14 @@ func ParseRecipients(f io.Reader) ([]Recipient, error) {
|
||||
}
|
||||
return recs, nil
|
||||
}
|
||||
|
||||
func parseRecipient(arg string) (Recipient, error) {
|
||||
switch {
|
||||
case strings.HasPrefix(arg, "age1pq1"):
|
||||
return ParseHybridRecipient(arg)
|
||||
case strings.HasPrefix(arg, "age1"):
|
||||
return ParseX25519Recipient(arg)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown recipient type: %q", arg)
|
||||
}
|
||||
}
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
// Copyright 2025 The age Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package age
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"filippo.io/age/internal/bech32"
|
||||
"filippo.io/age/internal/format"
|
||||
"filippo.io/hpke"
|
||||
"golang.org/x/crypto/chacha20poly1305"
|
||||
)
|
||||
|
||||
const pqLabel = "age-encryption.org/mlkem768x25519"
|
||||
|
||||
// HybridRecipient is the standard age public key. Messages encrypted to
|
||||
// this recipient can be decrypted with the corresponding [HybridIdentity].
|
||||
//
|
||||
// This recipient is safe against future cryptographically-relevant quantum
|
||||
// computers, and can only be used along with other post-quantum recipients.
|
||||
//
|
||||
// This recipient is anonymous, in the sense that an attacker can't tell from
|
||||
// the message alone if it is encrypted to a certain recipient.
|
||||
type HybridRecipient struct {
|
||||
pk hpke.PublicKey
|
||||
}
|
||||
|
||||
var _ Recipient = &HybridRecipient{}
|
||||
|
||||
// newHybridRecipient returns a new [HybridRecipient] from a raw HPKE public key.
|
||||
func newHybridRecipient(publicKey []byte) (*HybridRecipient, error) {
|
||||
pk, err := hpke.MLKEM768X25519().NewPublicKey(publicKey)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid MLKEM768-X25519 public key")
|
||||
}
|
||||
return &HybridRecipient{pk: pk}, nil
|
||||
}
|
||||
|
||||
// ParseHybridRecipient returns a new [HybridRecipient] from a Bech32 public key
|
||||
// encoding with the "age1pq1" prefix.
|
||||
func ParseHybridRecipient(s string) (*HybridRecipient, error) {
|
||||
t, k, err := bech32.Decode(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("malformed recipient %q: %v", s, err)
|
||||
}
|
||||
if t != "age1pq" {
|
||||
return nil, fmt.Errorf("malformed recipient %q: invalid type %q", s, t)
|
||||
}
|
||||
r, err := newHybridRecipient(k)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("malformed recipient %q: %v", s, err)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (r *HybridRecipient) Wrap(fileKey []byte) ([]*Stanza, error) {
|
||||
s, _, err := r.WrapWithLabels(fileKey)
|
||||
return s, err
|
||||
}
|
||||
|
||||
// WrapWithLabels implements [RecipientWithLabels], returning a single
|
||||
// "postquantum" label. This ensures a HybridRecipient can't be mixed with other
|
||||
// recipients that would defeat its post-quantum security.
|
||||
//
|
||||
// To unsafely bypass this restriction, wrap HybridRecipient in a [Recipient]
|
||||
// type that doesn't expose WrapWithLabels.
|
||||
func (r *HybridRecipient) WrapWithLabels(fileKey []byte) ([]*Stanza, []string, error) {
|
||||
enc, s, err := hpke.NewSender(r.pk, hpke.HKDFSHA256(), hpke.ChaCha20Poly1305(), []byte(pqLabel))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to set up HPKE sender: %v", err)
|
||||
}
|
||||
ct, err := s.Seal(nil, fileKey)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to encrypt file key: %v", err)
|
||||
}
|
||||
|
||||
l := &Stanza{
|
||||
Type: "mlkem768x25519",
|
||||
Args: []string{format.EncodeToString(enc)},
|
||||
Body: ct,
|
||||
}
|
||||
|
||||
return []*Stanza{l}, []string{"postquantum"}, nil
|
||||
}
|
||||
|
||||
// String returns the Bech32 public key encoding of r.
|
||||
func (r *HybridRecipient) String() string {
|
||||
s, _ := bech32.Encode("age1pq", r.pk.Bytes())
|
||||
return s
|
||||
}
|
||||
|
||||
// HybridIdentity is the standard age private key, which can decrypt messages
|
||||
// encrypted to the corresponding [HybridRecipient].
|
||||
type HybridIdentity struct {
|
||||
k hpke.PrivateKey
|
||||
}
|
||||
|
||||
var _ Identity = &HybridIdentity{}
|
||||
|
||||
// newHybridIdentity returns a new [HybridIdentity] from a raw HPKE private key.
|
||||
func newHybridIdentity(secretKey []byte) (*HybridIdentity, error) {
|
||||
k, err := hpke.MLKEM768X25519().NewPrivateKey(secretKey)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid MLKEM768-X25519 secret key")
|
||||
}
|
||||
return &HybridIdentity{k: k}, nil
|
||||
}
|
||||
|
||||
// GenerateHybridIdentity randomly generates a new [HybridIdentity].
|
||||
func GenerateHybridIdentity() (*HybridIdentity, error) {
|
||||
k, err := hpke.MLKEM768X25519().GenerateKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate post-quantum identity: %v", err)
|
||||
}
|
||||
return &HybridIdentity{k: k}, nil
|
||||
}
|
||||
|
||||
// ParseHybridIdentity returns a new [HybridIdentity] from a Bech32 private key
|
||||
// encoding with the "AGE-SECRET-KEY-PQ-1" prefix.
|
||||
func ParseHybridIdentity(s string) (*HybridIdentity, error) {
|
||||
t, k, err := bech32.Decode(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("malformed secret key: %v", err)
|
||||
}
|
||||
if t != "AGE-SECRET-KEY-PQ-" {
|
||||
return nil, fmt.Errorf("malformed secret key: unknown type %q", t)
|
||||
}
|
||||
r, err := newHybridIdentity(k)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("malformed secret key: %v", err)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (i *HybridIdentity) Unwrap(stanzas []*Stanza) ([]byte, error) {
|
||||
return multiUnwrap(i.unwrap, stanzas)
|
||||
}
|
||||
|
||||
func (i *HybridIdentity) unwrap(block *Stanza) ([]byte, error) {
|
||||
if block.Type != "mlkem768x25519" {
|
||||
return nil, ErrIncorrectIdentity
|
||||
}
|
||||
if len(block.Args) != 1 {
|
||||
return nil, errors.New("invalid mlkem768x25519 recipient block")
|
||||
}
|
||||
enc, err := format.DecodeString(block.Args[0])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse mlkem768x25519 recipient: %v", err)
|
||||
}
|
||||
if len(block.Body) != fileKeySize+chacha20poly1305.Overhead {
|
||||
return nil, errIncorrectCiphertextSize
|
||||
}
|
||||
|
||||
r, err := hpke.NewRecipient(enc, i.k, hpke.HKDFSHA256(), hpke.ChaCha20Poly1305(), []byte(pqLabel))
|
||||
if err != nil {
|
||||
// MLKEM768-X25519 does implicit rejection, so a mismatched key does not
|
||||
// hit this error path, but is only detected later when trying to open.
|
||||
return nil, fmt.Errorf("invalid mlkem768x25519 recipient: %v", err)
|
||||
}
|
||||
fileKey, err := r.Open(nil, block.Body)
|
||||
if err != nil {
|
||||
return nil, ErrIncorrectIdentity
|
||||
}
|
||||
return fileKey, nil
|
||||
}
|
||||
|
||||
// Recipient returns the public [HybridRecipient] value corresponding to i.
|
||||
func (i *HybridIdentity) Recipient() *HybridRecipient {
|
||||
return &HybridRecipient{pk: i.k.PublicKey()}
|
||||
}
|
||||
|
||||
// String returns the Bech32 private key encoding of i.
|
||||
func (i *HybridIdentity) String() string {
|
||||
b, _ := i.k.Bytes()
|
||||
s, _ := bech32.Encode("AGE-SECRET-KEY-PQ-", b)
|
||||
return strings.ToUpper(s)
|
||||
}
|
||||
+12
-4
@@ -27,7 +27,7 @@ const scryptLabel = "age-encryption.org/v1/scrypt"
|
||||
// for the same file.
|
||||
//
|
||||
// Its use is not recommended for automated systems, which should prefer
|
||||
// X25519Recipient.
|
||||
// [HybridRecipient] or [X25519Recipient].
|
||||
type ScryptRecipient struct {
|
||||
password []byte
|
||||
workFactor int
|
||||
@@ -150,14 +150,20 @@ func (i *ScryptIdentity) Unwrap(stanzas []*Stanza) ([]byte, error) {
|
||||
return nil, errors.New("an scrypt recipient must be the only one")
|
||||
}
|
||||
}
|
||||
return multiUnwrap(i.unwrap, stanzas)
|
||||
for _, s := range stanzas {
|
||||
if s.Type != "scrypt" {
|
||||
continue
|
||||
}
|
||||
return i.unwrap(s)
|
||||
}
|
||||
return nil, fmt.Errorf("%w: file is not passphrase-encrypted", ErrIncorrectIdentity)
|
||||
}
|
||||
|
||||
var digitsRe = regexp.MustCompile(`^[1-9][0-9]*$`)
|
||||
|
||||
func (i *ScryptIdentity) unwrap(block *Stanza) ([]byte, error) {
|
||||
if block.Type != "scrypt" {
|
||||
return nil, ErrIncorrectIdentity
|
||||
return nil, errors.New("internal error: unwrap called on non-scrypt stanza")
|
||||
}
|
||||
if len(block.Args) != 2 {
|
||||
return nil, errors.New("invalid scrypt recipient block")
|
||||
@@ -200,7 +206,9 @@ func (i *ScryptIdentity) unwrap(block *Stanza) ([]byte, error) {
|
||||
if err == errIncorrectCiphertextSize {
|
||||
return nil, errors.New("invalid scrypt recipient block: incorrect file key size")
|
||||
} else if err != nil {
|
||||
return nil, ErrIncorrectIdentity
|
||||
// Wrap [ErrIncorrectIdentity] so that multiple passphrases can be tried
|
||||
// in sequence by passing multiple [ScryptIdentity] values to [Decrypt].
|
||||
return nil, fmt.Errorf("%w: incorrect passphrase", ErrIncorrectIdentity)
|
||||
}
|
||||
return fileKey, nil
|
||||
}
|
||||
+6
-4
@@ -21,8 +21,9 @@ import (
|
||||
|
||||
const x25519Label = "age-encryption.org/v1/X25519"
|
||||
|
||||
// X25519Recipient is the standard age public key. Messages encrypted to this
|
||||
// recipient can be decrypted with the corresponding X25519Identity.
|
||||
// X25519Recipient is the standard age pre-quantum public key. Messages
|
||||
// encrypted to this recipient can be decrypted with the corresponding
|
||||
// [X25519Identity]. For post-quantum resistance, use [HybridRecipient].
|
||||
//
|
||||
// This recipient is anonymous, in the sense that an attacker can't tell from
|
||||
// the message alone if it is encrypted to a certain recipient.
|
||||
@@ -105,8 +106,9 @@ func (r *X25519Recipient) String() string {
|
||||
return s
|
||||
}
|
||||
|
||||
// X25519Identity is the standard age private key, which can decrypt messages
|
||||
// encrypted to the corresponding X25519Recipient.
|
||||
// X25519Identity is the standard pre-quantum age private key, which can decrypt
|
||||
// messages encrypted to the corresponding [X25519Recipient]. For post-quantum
|
||||
// resistance, use [HybridIdentity].
|
||||
type X25519Identity struct {
|
||||
secretKey, ourPublicKey []byte
|
||||
}
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
Copyright 2009 The Go Authors.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above
|
||||
copyright notice, this list of conditions and the following disclaimer
|
||||
in the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
* Neither the name of Google LLC nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
// Copyright 2025 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package hpke
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/crypto/chacha20poly1305"
|
||||
)
|
||||
|
||||
// The AEAD is one of the three components of an HPKE ciphersuite, implementing
|
||||
// symmetric encryption.
|
||||
type AEAD interface {
|
||||
ID() uint16
|
||||
keySize() int
|
||||
nonceSize() int
|
||||
aead(key []byte) (cipher.AEAD, error)
|
||||
}
|
||||
|
||||
// NewAEAD returns the AEAD implementation for the given AEAD ID.
|
||||
//
|
||||
// Applications are encouraged to use specific implementations like [AES128GCM]
|
||||
// or [ChaCha20Poly1305] instead, unless runtime agility is required.
|
||||
func NewAEAD(id uint16) (AEAD, error) {
|
||||
switch id {
|
||||
case 0x0001: // AES-128-GCM
|
||||
return AES128GCM(), nil
|
||||
case 0x0002: // AES-256-GCM
|
||||
return AES256GCM(), nil
|
||||
case 0x0003: // ChaCha20Poly1305
|
||||
return ChaCha20Poly1305(), nil
|
||||
case 0xFFFF: // Export-only
|
||||
return ExportOnly(), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported AEAD %04x", id)
|
||||
}
|
||||
}
|
||||
|
||||
// AES128GCM returns an AES-128-GCM AEAD implementation.
|
||||
func AES128GCM() AEAD { return aes128GCM }
|
||||
|
||||
// AES256GCM returns an AES-256-GCM AEAD implementation.
|
||||
func AES256GCM() AEAD { return aes256GCM }
|
||||
|
||||
// ChaCha20Poly1305 returns a ChaCha20Poly1305 AEAD implementation.
|
||||
func ChaCha20Poly1305() AEAD { return chacha20poly1305AEAD }
|
||||
|
||||
// ExportOnly returns a placeholder AEAD implementation that cannot encrypt or
|
||||
// decrypt, but only export secrets with [Sender.Export] or [Recipient.Export].
|
||||
//
|
||||
// When this is used, [Sender.Seal] and [Recipient.Open] return errors.
|
||||
func ExportOnly() AEAD { return exportOnlyAEAD{} }
|
||||
|
||||
type aead struct {
|
||||
nK int
|
||||
nN int
|
||||
new func([]byte) (cipher.AEAD, error)
|
||||
id uint16
|
||||
}
|
||||
|
||||
var aes128GCM = &aead{
|
||||
nK: 128 / 8,
|
||||
nN: 96 / 8,
|
||||
new: newAESGCM,
|
||||
id: 0x0001,
|
||||
}
|
||||
|
||||
var aes256GCM = &aead{
|
||||
nK: 256 / 8,
|
||||
nN: 96 / 8,
|
||||
new: newAESGCM,
|
||||
id: 0x0002,
|
||||
}
|
||||
|
||||
var chacha20poly1305AEAD = &aead{
|
||||
nK: chacha20poly1305.KeySize,
|
||||
nN: chacha20poly1305.NonceSize,
|
||||
new: chacha20poly1305.New,
|
||||
id: 0x0003,
|
||||
}
|
||||
|
||||
func newAESGCM(key []byte) (cipher.AEAD, error) {
|
||||
b, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cipher.NewGCM(b)
|
||||
}
|
||||
|
||||
func (a *aead) ID() uint16 {
|
||||
return a.id
|
||||
}
|
||||
|
||||
func (a *aead) aead(key []byte) (cipher.AEAD, error) {
|
||||
if len(key) != a.nK {
|
||||
return nil, errors.New("invalid key size")
|
||||
}
|
||||
return a.new(key)
|
||||
}
|
||||
|
||||
func (a *aead) keySize() int {
|
||||
return a.nK
|
||||
}
|
||||
|
||||
func (a *aead) nonceSize() int {
|
||||
return a.nN
|
||||
}
|
||||
|
||||
type exportOnlyAEAD struct{}
|
||||
|
||||
func (exportOnlyAEAD) ID() uint16 {
|
||||
return 0xFFFF
|
||||
}
|
||||
|
||||
func (exportOnlyAEAD) aead(key []byte) (cipher.AEAD, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (exportOnlyAEAD) keySize() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (exportOnlyAEAD) nonceSize() int {
|
||||
return 0
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
// Package ecdh defines an additional interface that will be added to the
|
||||
// crypto/ecdh package in Go 1.26+.
|
||||
package ecdh
|
||||
|
||||
import "crypto/ecdh"
|
||||
|
||||
// KeyExchanger is an interface for an opaque private key that can be used for
|
||||
// key exchange operations. For example, an ECDH key kept in a hardware module.
|
||||
//
|
||||
// It is implemented by [ecdh.PrivateKey].
|
||||
type KeyExchanger interface {
|
||||
PublicKey() *ecdh.PublicKey
|
||||
Curve() ecdh.Curve
|
||||
ECDH(*ecdh.PublicKey) ([]byte, error)
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package ecdh
|
||||
|
||||
import "crypto/ecdh"
|
||||
|
||||
// This file contains stubs to allow importing only this package instead of
|
||||
// crypto/ecdh, to minimize the diff.
|
||||
|
||||
type Curve = ecdh.Curve
|
||||
type PrivateKey = ecdh.PrivateKey
|
||||
type PublicKey = ecdh.PublicKey
|
||||
|
||||
func X25519() Curve { return ecdh.X25519() }
|
||||
func P256() Curve { return ecdh.P256() }
|
||||
func P384() Curve { return ecdh.P384() }
|
||||
func P521() Curve { return ecdh.P521() }
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
// Package crypto defines additional interfaces that will be added to the
|
||||
// crypto package in Go 1.26+.
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"crypto/mlkem"
|
||||
)
|
||||
|
||||
// KeyExchanger is an interface for an opaque private key that can be used for
|
||||
// key exchange operations. For example, an ECDH key kept in a hardware module.
|
||||
//
|
||||
// It is implemented by [ecdh.PrivateKey].
|
||||
type KeyExchanger interface {
|
||||
PublicKey() *ecdh.PublicKey
|
||||
Curve() ecdh.Curve
|
||||
ECDH(*ecdh.PublicKey) ([]byte, error)
|
||||
}
|
||||
|
||||
// Encapsulator is an interface for a public KEM key that can be used for
|
||||
// encapsulation operations.
|
||||
//
|
||||
// It is implemented, for example, by [crypto/mlkem.EncapsulationKey768].
|
||||
type Encapsulator interface {
|
||||
Bytes() []byte
|
||||
Encapsulate() (sharedKey, ciphertext []byte)
|
||||
}
|
||||
|
||||
// Decapsulator is an interface for an opaque private KEM key that can be used for
|
||||
// decapsulation operations. For example, an ML-KEM key kept in a hardware module.
|
||||
//
|
||||
// It will be implemented by [crypto/mlkem.DecapsulationKey768] in Go 1.26+.
|
||||
// In the meantime, use [DecapsulatorFromDecapsulationKey768] and
|
||||
// [DecapsulatorFromDecapsulationKey1024].
|
||||
type Decapsulator interface {
|
||||
Encapsulator() Encapsulator
|
||||
Decapsulate(ciphertext []byte) (sharedKey []byte, err error)
|
||||
}
|
||||
|
||||
// DecapsulatorFromDecapsulationKey768 wraps an ML-KEM-768 decapsulation key
|
||||
// into a [Decapsulator], until Go 1.26+ where [crypto/mlkem.DecapsulationKey768]
|
||||
// implements it natively.
|
||||
func DecapsulatorFromDecapsulationKey768(dk *mlkem.DecapsulationKey768) Decapsulator {
|
||||
return &mlkem768Decapsulator{dk}
|
||||
}
|
||||
|
||||
type mlkem768Decapsulator struct {
|
||||
*mlkem.DecapsulationKey768
|
||||
}
|
||||
|
||||
func (d *mlkem768Decapsulator) Encapsulator() Encapsulator {
|
||||
return d.EncapsulationKey()
|
||||
}
|
||||
|
||||
// DecapsulatorFromDecapsulationKey1024 wraps an ML-KEM-1024 decapsulation key
|
||||
// into a [Decapsulator], until Go 1.26+ where [crypto/mlkem.DecapsulationKey1024]
|
||||
// implements it natively.
|
||||
func DecapsulatorFromDecapsulationKey1024(dk *mlkem.DecapsulationKey1024) Decapsulator {
|
||||
return &mlkem1024Decapsulator{dk}
|
||||
}
|
||||
|
||||
type mlkem1024Decapsulator struct {
|
||||
*mlkem.DecapsulationKey1024
|
||||
}
|
||||
|
||||
func (d *mlkem1024Decapsulator) Encapsulator() Encapsulator {
|
||||
return d.EncapsulationKey()
|
||||
}
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
# HPKE Hybrid KEMs
|
||||
|
||||
[filippo.io/hpke-pq](https://filippo.io/hpke-pq)
|
||||
|
||||
This document is a simplified and self-contained implementation reference for
|
||||
the MLKEM768-X25519, MLKEM768-P256, and MLKEM1024-P384 hybrid HPKE KEMs,
|
||||
specified in [draft-ietf-hpke-pq-03][], [draft-irtf-cfrg-hybrid-kems-07][],
|
||||
[draft-irtf-cfrg-concrete-hybrid-kems-02][], and [draft-ietf-hpke-hpke-02].
|
||||
|
||||
It compensates for the need to cross-reference four documents, with different
|
||||
nomenclature (including functions and components with the same name but
|
||||
different behavior), alternative irrelevant definitions (the UG, UK, and CK
|
||||
frameworks), and multiple KEM abstraction layers.
|
||||
|
||||
## Conventions used in this document
|
||||
|
||||
`||` denotes concatenation. `[N:M]` denotes the byte slice from index N (inclusive)
|
||||
to index M (exclusive). Strings quoted with `""` are encoded as ASCII. Values in
|
||||
code blocks are hex encoded byte strings. `random(N)` denotes N bytes of CSPRNG
|
||||
output. All lengths are in bytes.
|
||||
|
||||
ML-KEM.KeyGen_internal, ML-KEM.Encaps, and ML-KEM.Decaps are defined in
|
||||
[FIPS 203][]. `SHAKE256(s, L)` is an invocation of `SHAKE256(s, 8*L)` defined in
|
||||
[FIPS 202][]. SHA3-256 is defined in [FIPS 202][].
|
||||
|
||||
## KEM definitions
|
||||
|
||||
| Parameter | MLKEM768-X25519 | MLKEM768-P256 | MLKEM1024-P384 |
|
||||
| ----------------- | ------------------ | ------------------ | ------------------ |
|
||||
| ML-KEM parameters | ML-KEM-768 | ML-KEM-768 | ML-KEM-1024 |
|
||||
| Group | Curve25519 | P-256 | P-384 |
|
||||
| KEM identifier | 0x647a | 0x0050 | 0x0051 |
|
||||
| Nsecret | 32 | 32 | 32 |
|
||||
| Nenc | 1120 | 1153 | 1665 |
|
||||
| Npk | 1216 | 1249 | 1665 |
|
||||
| Nsk | 32 | 32 | 32 |
|
||||
| Label | `"\.//^\"` | `"MLKEM768-P256"` | `"MLKEM1024-P384"` |
|
||||
| KEM.Nct | 1088 | 1088 | 1568 |
|
||||
| KEM.Nek | 1184 | 1184 | 1568 |
|
||||
| KEM.Nseed | 64 | 64 | 64 |
|
||||
| Group.Nelem | 32 | 65 | 97 |
|
||||
| Group.Nseed | 32 | 128 | 48 |
|
||||
| Group.Nscalar | N/A | 32 | 48 |
|
||||
|
||||
The MLKEM768-X25519 Label is alternatively encoded as
|
||||
|
||||
5c2e2f2f5e5c
|
||||
|
||||
## KEM functions
|
||||
|
||||
```
|
||||
def GenerateKeyPair():
|
||||
seed = random(32)
|
||||
|
||||
ek_PQ, ek_T, _, _ = expandKey(seed)
|
||||
ek = ek_PQ || ek_T
|
||||
|
||||
return (seed, ek)
|
||||
|
||||
def DeriveKeyPair(ikm):
|
||||
# SHAKE256.LabeledDerive is part of the single-stage KDF described in
|
||||
# draft-ietf-hpke-hpke-02 and defined in draft-ietf-hpke-pq-03, but is
|
||||
# reproduced below for convenience.
|
||||
seed = SHAKE256.LabeledDerive(ikm, "DeriveKeyPair", "", 32)
|
||||
|
||||
ek_PQ, ek_T, _, _ = expandKey(seed)
|
||||
ek = ek_PQ || ek_T
|
||||
|
||||
return (seed, ek)
|
||||
|
||||
def Encaps(ek):
|
||||
ek_PQ = ek[0 : KEM.Nek]
|
||||
ek_T = ek[KEM.Nek : KEM.Nek + Group.Nelem]
|
||||
|
||||
ss_PQ, ct_PQ = ML-KEM.Encaps(ek_PQ)
|
||||
|
||||
sk_E = Group.RandomScalar(random(Group.Nseed))
|
||||
ct_T = Group.Exp(Group.G, sk_E)
|
||||
ss_T = Group.ElementToSharedSecret(Group.Exp(ek_T, sk_E))
|
||||
|
||||
ss = SHA3-256(ss_PQ || ss_T || ct_T || ek_T || Label)
|
||||
ct = ct_PQ || ct_T
|
||||
|
||||
return (ss, ct)
|
||||
|
||||
def Decaps(seed, ct):
|
||||
ct_PQ = ct[0 : KEM.Nct]
|
||||
ct_T = ct[KEM.Nct : KEM.Nct + Group.Nelem]
|
||||
|
||||
ek_PQ, ek_T, dk_PQ, dk_T = expandKey(seed)
|
||||
|
||||
ss_PQ = ML-KEM.Decaps(dk_PQ, ct_PQ)
|
||||
ss_T = Group.ElementToSharedSecret(Group.Exp(ct_T, dk_T))
|
||||
|
||||
ss = SHA3-256(ss_PQ || ss_T || ct_T || ek_T || Label)
|
||||
|
||||
return ss
|
||||
|
||||
def expandKey(seed):
|
||||
seed_full = SHAKE256(seed, KEM.Nseed + Group.Nseed)
|
||||
seed_PQ = seed_full[0 : KEM.Nseed]
|
||||
seed_T = seed_full[KEM.Nseed : KEM.Nseed + Group.Nseed]
|
||||
|
||||
# Note that even if expandKey returns the semi-expanded ML-KEM decapsulation
|
||||
# key dk_PQ to use FIPS 203 definitions, that format should be avoided and
|
||||
# instead seed_PQ should be expanded directly into the implementation's
|
||||
# internal ML-KEM private representation.
|
||||
(ek_PQ, dk_PQ) = ML-KEM.KeyGen_internal(seed_PQ)
|
||||
dk_T = Group.RandomScalar(seed_T)
|
||||
ek_T = Group.Exp(Group.G, dk_T)
|
||||
|
||||
return (ek_PQ, ek_T, dk_PQ, dk_T)
|
||||
```
|
||||
|
||||
There is no distinction between a private/public key and its serialization:
|
||||
there is no abstract key format, only byte strings. In practice, implementations
|
||||
will probably want to load keys into pairs of internal representations, and
|
||||
serialize them back to their byte string format when needed.
|
||||
|
||||
The IETF/IRTF documents lack a specified way to turn a private key into public
|
||||
key, although it can be inferred from the key generation process. We define such
|
||||
a process here as `PrivateKeyToPublicKey`.
|
||||
|
||||
```
|
||||
def PrivateKeyToPublicKey(seed):
|
||||
ek_PQ, ek_T, _, _ = expandKey(seed)
|
||||
ek = ek_PQ || ek_T
|
||||
|
||||
return ek
|
||||
```
|
||||
|
||||
## Group definitions
|
||||
|
||||
### Curve25519
|
||||
|
||||
Group.Exp is the X25519 function defined in [RFC 7748][].
|
||||
|
||||
Group.G is the canonical generator, which encodes to
|
||||
|
||||
0900000000000000000000000000000000000000000000000000000000000000
|
||||
|
||||
consistently with [RFC 7748, Section 4.1][] and [RFC 7748, Section 6.1][].
|
||||
|
||||
Group.RandomScalar and Group.ElementToSharedSecret are the identity.
|
||||
|
||||
### P-256 and P-384
|
||||
|
||||
The NIST P-256 and P-384 elliptic curves are defined in [SP800-186][].
|
||||
|
||||
`Group.Exp(p, x)` computes scalar multiplication between the input element p and
|
||||
the scalar x. The input element p and the output element have length Group.Nelem
|
||||
and are encoded in uncompressed representation using the
|
||||
Elliptic-Curve-Point-to-Octet-String and Octet-String-to-Elliptic-Curve-Point
|
||||
functions defined in [SEC 1, Version 2.0][]. The input scalar x has length
|
||||
Group.Nscalar and is encoded in big-endian representation using the I2OSP and
|
||||
OS2IP functions defined in [RFC 8017][].
|
||||
|
||||
Group.G is the canonical generator, which encodes to
|
||||
|
||||
046b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c29
|
||||
64fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5
|
||||
|
||||
for P-256, and to
|
||||
|
||||
04aa87ca22be8b05378eb1c71ef320ad746e1d3b628ba79b9859f741e082542a385502f25dbf55296c3a545e3872760ab
|
||||
73617de4a96262c6f5d9e98bf9292dc29f8f41dbd289a147ce9da3113b5f0b8c00a60b1ce1d7e819d7a431d7c90ea0e5f
|
||||
|
||||
for P-384, consistently with [SP800-186][], Section 3.2.1.
|
||||
|
||||
```
|
||||
def RandomScalar(seed):
|
||||
start = 0
|
||||
end = Nscalar
|
||||
sk = seed[start : end]
|
||||
while OS2IP(sk) == 0 || OS2IP(sk) >= OS2IP(Group.N):
|
||||
start = end
|
||||
end = end + Nscalar
|
||||
if end > len(seed):
|
||||
# This happens with cryptographically negligible probability.
|
||||
# The chance of a single rejection is < 2^-32 for P-256 and
|
||||
# < 2^-192 for P-384. The chance of reaching this is thus
|
||||
# < 2^-128 for P-256 and < 2^-192 for P-384.
|
||||
raise Exception("Rejection sampling failed")
|
||||
sk = seed[start : end]
|
||||
return sk
|
||||
```
|
||||
|
||||
Group.N is the order of the curve's group, which encodes to
|
||||
|
||||
ffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551
|
||||
|
||||
for P-256, and to
|
||||
|
||||
ffffffffffffffffffffffffffffffffffffffffffffffffc7634d81f4372ddf581a0db248b0a77aecec196accc52973
|
||||
|
||||
for P-384, consistently with [SP800-186][], Section 3.2.1.
|
||||
|
||||
Group.ElementToSharedSecret encodes the input element as an X coordinate using
|
||||
the Field-Element-to-Octet-String function in [SEC 1, Version 2.0][].
|
||||
|
||||
> Note that since the scalar x is always derived uniformly at random, the chance
|
||||
> of it being zero are cryptographically negligible. Moreover,
|
||||
> Octet-String-to-Elliptic-Curve-Point never decodes the point at infinity from
|
||||
> a string of Group.Nelem bytes. Since NIST P curves have prime order, this
|
||||
> means that the output of Group.Exp and input to Group.ElementToSharedSecret is
|
||||
> also never the point at infinity.
|
||||
|
||||
## SHAKE256.LabeledDerive
|
||||
|
||||
SHAKE256.LabeledDerive is used by DeriveKeyPair, and is part of the single-stage
|
||||
KDF specified across [draft-ietf-hpke-hpke-02][] and [draft-ietf-hpke-pq-03][],
|
||||
but is reproduced below for convenience.
|
||||
|
||||
```
|
||||
def SHAKE256.LabeledDerive(ikm, label, context, L):
|
||||
suite_id = concat("KEM", I2OSP(kem_id, 2))
|
||||
prefixed_label = I2OSP(len(label), 2) || label
|
||||
labeled_ikm = ikm || "HPKE-v1" || suite_id || prefixed_label || I2OSP(L, 2) || context
|
||||
return SHAKE256(labeled_ikm, L)
|
||||
```
|
||||
|
||||
I2OSP is defined in [RFC 8017][], and `kem_id` is the KEM identifier.
|
||||
|
||||
[draft-ietf-hpke-hpke-02]: https://datatracker.ietf.org/doc/html/draft-ietf-hpke-hpke-02
|
||||
[draft-ietf-hpke-pq-03]: https://datatracker.ietf.org/doc/html/draft-ietf-hpke-pq-03
|
||||
[draft-irtf-cfrg-hybrid-kems-07]: https://datatracker.ietf.org/doc/html/draft-irtf-cfrg-hybrid-kems-07
|
||||
[draft-irtf-cfrg-concrete-hybrid-kems-02]: https://datatracker.ietf.org/doc/html/draft-irtf-cfrg-concrete-hybrid-kems-02
|
||||
[RFC 7748]: https://rfc-editor.org/rfc/rfc7748.html
|
||||
[RFC 7748, Section 4.1]: https://rfc-editor.org/rfc/rfc7748.html#section-4.1
|
||||
[RFC 7748, Section 6.1]: https://rfc-editor.org/rfc/rfc7748.html#section-6.1
|
||||
[RFC 8017]: https://datatracker.ietf.org/doc/html/rfc8017
|
||||
[FIPS 202]: https://doi.org/10.6028/NIST.FIPS.202
|
||||
[FIPS 203]: https://doi.org/10.6028/NIST.FIPS.203
|
||||
[SP800-186]: https://doi.org/10.6028/NIST.SP.800-186
|
||||
[SEC 1, Version 2.0]: https://www.secg.org/sec1-v2.pdf
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
// Copyright 2024 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// Package hpke implements Hybrid Public Key Encryption (HPKE) as defined in
|
||||
// [RFC 9180].
|
||||
//
|
||||
// [RFC 9180]: https://www.rfc-editor.org/rfc/rfc9180.html
|
||||
package hpke
|
||||
|
||||
import (
|
||||
"crypto/cipher"
|
||||
"errors"
|
||||
|
||||
"filippo.io/hpke/internal/byteorder"
|
||||
)
|
||||
|
||||
type context struct {
|
||||
suiteID []byte
|
||||
|
||||
export func(string, uint16) ([]byte, error)
|
||||
|
||||
aead cipher.AEAD
|
||||
baseNonce []byte
|
||||
// seqNum starts at zero and is incremented for each Seal/Open call.
|
||||
// 64 bits are enough not to overflow for 500 years at 1ns per operation.
|
||||
seqNum uint64
|
||||
}
|
||||
|
||||
// Sender is a sending HPKE context. It is instantiated with a specific KEM
|
||||
// encapsulation key (i.e. the public key), and it is stateful, incrementing the
|
||||
// nonce counter for each [Sender.Seal] call.
|
||||
type Sender struct {
|
||||
*context
|
||||
}
|
||||
|
||||
// Recipient is a receiving HPKE context. It is instantiated with a specific KEM
|
||||
// decapsulation key (i.e. the secret key), and it is stateful, incrementing the
|
||||
// nonce counter for each successful [Recipient.Open] call.
|
||||
type Recipient struct {
|
||||
*context
|
||||
}
|
||||
|
||||
func newContext(sharedSecret []byte, kemID uint16, kdf KDF, aead AEAD, info []byte) (*context, error) {
|
||||
sid := suiteID(kemID, kdf.ID(), aead.ID())
|
||||
|
||||
if kdf.oneStage() {
|
||||
secrets := make([]byte, 0, 2+2+len(sharedSecret))
|
||||
secrets = byteorder.BEAppendUint16(secrets, 0) // empty psk
|
||||
secrets = byteorder.BEAppendUint16(secrets, uint16(len(sharedSecret)))
|
||||
secrets = append(secrets, sharedSecret...)
|
||||
|
||||
ksContext := make([]byte, 0, 1+2+2+len(info))
|
||||
ksContext = append(ksContext, 0) // mode 0
|
||||
ksContext = byteorder.BEAppendUint16(ksContext, 0) // empty psk_id
|
||||
ksContext = byteorder.BEAppendUint16(ksContext, uint16(len(info)))
|
||||
ksContext = append(ksContext, info...)
|
||||
|
||||
secret, err := kdf.labeledDerive(sid, secrets, "secret", ksContext,
|
||||
uint16(aead.keySize()+aead.nonceSize()+kdf.size()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := secret[:aead.keySize()]
|
||||
baseNonce := secret[aead.keySize() : aead.keySize()+aead.nonceSize()]
|
||||
expSecret := secret[aead.keySize()+aead.nonceSize():]
|
||||
|
||||
a, err := aead.aead(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
export := func(exporterContext string, length uint16) ([]byte, error) {
|
||||
return kdf.labeledDerive(sid, expSecret, "sec", []byte(exporterContext), length)
|
||||
}
|
||||
|
||||
return &context{
|
||||
aead: a,
|
||||
suiteID: sid,
|
||||
export: export,
|
||||
baseNonce: baseNonce,
|
||||
}, nil
|
||||
}
|
||||
|
||||
pskIDHash, err := kdf.labeledExtract(sid, nil, "psk_id_hash", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
infoHash, err := kdf.labeledExtract(sid, nil, "info_hash", info)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ksContext := append([]byte{0}, pskIDHash...)
|
||||
ksContext = append(ksContext, infoHash...)
|
||||
|
||||
secret, err := kdf.labeledExtract(sid, sharedSecret, "secret", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, err := kdf.labeledExpand(sid, secret, "key", ksContext, uint16(aead.keySize()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a, err := aead.aead(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
baseNonce, err := kdf.labeledExpand(sid, secret, "base_nonce", ksContext, uint16(aead.nonceSize()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
expSecret, err := kdf.labeledExpand(sid, secret, "exp", ksContext, uint16(kdf.size()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
export := func(exporterContext string, length uint16) ([]byte, error) {
|
||||
return kdf.labeledExpand(sid, expSecret, "sec", []byte(exporterContext), length)
|
||||
}
|
||||
|
||||
return &context{
|
||||
aead: a,
|
||||
suiteID: sid,
|
||||
export: export,
|
||||
baseNonce: baseNonce,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewSender returns a sending HPKE context for the provided KEM encapsulation
|
||||
// key (i.e. the public key), and using the ciphersuite defined by the
|
||||
// combination of KEM, KDF, and AEAD.
|
||||
//
|
||||
// The info parameter is additional public information that must match between
|
||||
// sender and recipient.
|
||||
//
|
||||
// The returned enc ciphertext can be used to instantiate a matching receiving
|
||||
// HPKE context with the corresponding KEM decapsulation key.
|
||||
func NewSender(pk PublicKey, kdf KDF, aead AEAD, info []byte) (enc []byte, s *Sender, err error) {
|
||||
sharedSecret, encapsulatedKey, err := pk.encap()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
context, err := newContext(sharedSecret, pk.KEM().ID(), kdf, aead, info)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return encapsulatedKey, &Sender{context}, nil
|
||||
}
|
||||
|
||||
// NewRecipient returns a receiving HPKE context for the provided KEM
|
||||
// decapsulation key (i.e. the secret key), and using the ciphersuite defined by
|
||||
// the combination of KEM, KDF, and AEAD.
|
||||
//
|
||||
// The enc parameter must have been produced by a matching sending HPKE context
|
||||
// with the corresponding KEM encapsulation key. The info parameter is
|
||||
// additional public information that must match between sender and recipient.
|
||||
func NewRecipient(enc []byte, k PrivateKey, kdf KDF, aead AEAD, info []byte) (*Recipient, error) {
|
||||
sharedSecret, err := k.decap(enc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
context, err := newContext(sharedSecret, k.KEM().ID(), kdf, aead, info)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Recipient{context}, nil
|
||||
}
|
||||
|
||||
// Seal encrypts the provided plaintext, optionally binding to the additional
|
||||
// public data aad.
|
||||
//
|
||||
// Seal uses incrementing counters for each call, and Open on the receiving side
|
||||
// must be called in the same order as Seal.
|
||||
func (s *Sender) Seal(aad, plaintext []byte) ([]byte, error) {
|
||||
if s.aead == nil {
|
||||
return nil, errors.New("export-only instantiation")
|
||||
}
|
||||
ciphertext := s.aead.Seal(nil, s.nextNonce(), plaintext, aad)
|
||||
s.seqNum++
|
||||
return ciphertext, nil
|
||||
}
|
||||
|
||||
// Seal instantiates a single-use HPKE sending HPKE context like [NewSender],
|
||||
// and then encrypts the provided plaintext like [Sender.Seal] (with no aad).
|
||||
// Seal returns the concatenation of the encapsulated key and the ciphertext.
|
||||
func Seal(pk PublicKey, kdf KDF, aead AEAD, info, plaintext []byte) ([]byte, error) {
|
||||
enc, s, err := NewSender(pk, kdf, aead, info)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ct, err := s.Seal(nil, plaintext)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(enc, ct...), nil
|
||||
}
|
||||
|
||||
// Export produces a secret value derived from the shared key between sender and
|
||||
// recipient. length must be at most 65,535.
|
||||
func (s *Sender) Export(exporterContext string, length int) ([]byte, error) {
|
||||
if length < 0 || length > 0xFFFF {
|
||||
return nil, errors.New("invalid length")
|
||||
}
|
||||
return s.export(exporterContext, uint16(length))
|
||||
}
|
||||
|
||||
// Open decrypts the provided ciphertext, optionally binding to the additional
|
||||
// public data aad, or returns an error if decryption fails.
|
||||
//
|
||||
// Open uses incrementing counters for each successful call, and must be called
|
||||
// in the same order as Seal on the sending side.
|
||||
func (r *Recipient) Open(aad, ciphertext []byte) ([]byte, error) {
|
||||
if r.aead == nil {
|
||||
return nil, errors.New("export-only instantiation")
|
||||
}
|
||||
plaintext, err := r.aead.Open(nil, r.nextNonce(), ciphertext, aad)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.seqNum++
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// Open instantiates a single-use HPKE receiving HPKE context like [NewRecipient],
|
||||
// and then decrypts the provided ciphertext like [Recipient.Open] (with no aad).
|
||||
// ciphertext must be the concatenation of the encapsulated key and the actual ciphertext.
|
||||
func Open(k PrivateKey, kdf KDF, aead AEAD, info, ciphertext []byte) ([]byte, error) {
|
||||
encSize := k.KEM().encSize()
|
||||
if len(ciphertext) < encSize {
|
||||
return nil, errors.New("ciphertext too short")
|
||||
}
|
||||
enc, ciphertext := ciphertext[:encSize], ciphertext[encSize:]
|
||||
r, err := NewRecipient(enc, k, kdf, aead, info)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.Open(nil, ciphertext)
|
||||
}
|
||||
|
||||
// Export produces a secret value derived from the shared key between sender and
|
||||
// recipient. length must be at most 65,535.
|
||||
func (r *Recipient) Export(exporterContext string, length int) ([]byte, error) {
|
||||
if length < 0 || length > 0xFFFF {
|
||||
return nil, errors.New("invalid length")
|
||||
}
|
||||
return r.export(exporterContext, uint16(length))
|
||||
}
|
||||
|
||||
func (ctx *context) nextNonce() []byte {
|
||||
nonce := make([]byte, ctx.aead.NonceSize())
|
||||
byteorder.BEPutUint64(nonce[len(nonce)-8:], ctx.seqNum)
|
||||
for i := range ctx.baseNonce {
|
||||
nonce[i] ^= ctx.baseNonce[i]
|
||||
}
|
||||
return nonce
|
||||
}
|
||||
|
||||
func suiteID(kemID, kdfID, aeadID uint16) []byte {
|
||||
suiteID := make([]byte, 0, 4+2+2+2)
|
||||
suiteID = append(suiteID, []byte("HPKE")...)
|
||||
suiteID = byteorder.BEAppendUint16(suiteID, kemID)
|
||||
suiteID = byteorder.BEAppendUint16(suiteID, kdfID)
|
||||
suiteID = byteorder.BEAppendUint16(suiteID, aeadID)
|
||||
return suiteID
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
// Copyright 2024 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// Package byteorder provides functions for decoding and encoding
|
||||
// little and big endian integer types from/to byte slices.
|
||||
package byteorder
|
||||
|
||||
func LEUint16(b []byte) uint16 {
|
||||
_ = b[1] // bounds check hint to compiler; see golang.org/issue/14808
|
||||
return uint16(b[0]) | uint16(b[1])<<8
|
||||
}
|
||||
|
||||
func LEPutUint16(b []byte, v uint16) {
|
||||
_ = b[1] // early bounds check to guarantee safety of writes below
|
||||
b[0] = byte(v)
|
||||
b[1] = byte(v >> 8)
|
||||
}
|
||||
|
||||
func LEAppendUint16(b []byte, v uint16) []byte {
|
||||
return append(b,
|
||||
byte(v),
|
||||
byte(v>>8),
|
||||
)
|
||||
}
|
||||
|
||||
func LEUint32(b []byte) uint32 {
|
||||
_ = b[3] // bounds check hint to compiler; see golang.org/issue/14808
|
||||
return uint32(b[0]) | uint32(b[1])<<8 | uint32(b[2])<<16 | uint32(b[3])<<24
|
||||
}
|
||||
|
||||
func LEPutUint32(b []byte, v uint32) {
|
||||
_ = b[3] // early bounds check to guarantee safety of writes below
|
||||
b[0] = byte(v)
|
||||
b[1] = byte(v >> 8)
|
||||
b[2] = byte(v >> 16)
|
||||
b[3] = byte(v >> 24)
|
||||
}
|
||||
|
||||
func LEAppendUint32(b []byte, v uint32) []byte {
|
||||
return append(b,
|
||||
byte(v),
|
||||
byte(v>>8),
|
||||
byte(v>>16),
|
||||
byte(v>>24),
|
||||
)
|
||||
}
|
||||
|
||||
func LEUint64(b []byte) uint64 {
|
||||
_ = b[7] // bounds check hint to compiler; see golang.org/issue/14808
|
||||
return uint64(b[0]) | uint64(b[1])<<8 | uint64(b[2])<<16 | uint64(b[3])<<24 |
|
||||
uint64(b[4])<<32 | uint64(b[5])<<40 | uint64(b[6])<<48 | uint64(b[7])<<56
|
||||
}
|
||||
|
||||
func LEPutUint64(b []byte, v uint64) {
|
||||
_ = b[7] // early bounds check to guarantee safety of writes below
|
||||
b[0] = byte(v)
|
||||
b[1] = byte(v >> 8)
|
||||
b[2] = byte(v >> 16)
|
||||
b[3] = byte(v >> 24)
|
||||
b[4] = byte(v >> 32)
|
||||
b[5] = byte(v >> 40)
|
||||
b[6] = byte(v >> 48)
|
||||
b[7] = byte(v >> 56)
|
||||
}
|
||||
|
||||
func LEAppendUint64(b []byte, v uint64) []byte {
|
||||
return append(b,
|
||||
byte(v),
|
||||
byte(v>>8),
|
||||
byte(v>>16),
|
||||
byte(v>>24),
|
||||
byte(v>>32),
|
||||
byte(v>>40),
|
||||
byte(v>>48),
|
||||
byte(v>>56),
|
||||
)
|
||||
}
|
||||
|
||||
func BEUint16(b []byte) uint16 {
|
||||
_ = b[1] // bounds check hint to compiler; see golang.org/issue/14808
|
||||
return uint16(b[1]) | uint16(b[0])<<8
|
||||
}
|
||||
|
||||
func BEPutUint16(b []byte, v uint16) {
|
||||
_ = b[1] // early bounds check to guarantee safety of writes below
|
||||
b[0] = byte(v >> 8)
|
||||
b[1] = byte(v)
|
||||
}
|
||||
|
||||
func BEAppendUint16(b []byte, v uint16) []byte {
|
||||
return append(b,
|
||||
byte(v>>8),
|
||||
byte(v),
|
||||
)
|
||||
}
|
||||
|
||||
func BEUint32(b []byte) uint32 {
|
||||
_ = b[3] // bounds check hint to compiler; see golang.org/issue/14808
|
||||
return uint32(b[3]) | uint32(b[2])<<8 | uint32(b[1])<<16 | uint32(b[0])<<24
|
||||
}
|
||||
|
||||
func BEPutUint32(b []byte, v uint32) {
|
||||
_ = b[3] // early bounds check to guarantee safety of writes below
|
||||
b[0] = byte(v >> 24)
|
||||
b[1] = byte(v >> 16)
|
||||
b[2] = byte(v >> 8)
|
||||
b[3] = byte(v)
|
||||
}
|
||||
|
||||
func BEAppendUint32(b []byte, v uint32) []byte {
|
||||
return append(b,
|
||||
byte(v>>24),
|
||||
byte(v>>16),
|
||||
byte(v>>8),
|
||||
byte(v),
|
||||
)
|
||||
}
|
||||
|
||||
func BEUint64(b []byte) uint64 {
|
||||
_ = b[7] // bounds check hint to compiler; see golang.org/issue/14808
|
||||
return uint64(b[7]) | uint64(b[6])<<8 | uint64(b[5])<<16 | uint64(b[4])<<24 |
|
||||
uint64(b[3])<<32 | uint64(b[2])<<40 | uint64(b[1])<<48 | uint64(b[0])<<56
|
||||
}
|
||||
|
||||
func BEPutUint64(b []byte, v uint64) {
|
||||
_ = b[7] // early bounds check to guarantee safety of writes below
|
||||
b[0] = byte(v >> 56)
|
||||
b[1] = byte(v >> 48)
|
||||
b[2] = byte(v >> 40)
|
||||
b[3] = byte(v >> 32)
|
||||
b[4] = byte(v >> 24)
|
||||
b[5] = byte(v >> 16)
|
||||
b[6] = byte(v >> 8)
|
||||
b[7] = byte(v)
|
||||
}
|
||||
|
||||
func BEAppendUint64(b []byte, v uint64) []byte {
|
||||
return append(b,
|
||||
byte(v>>56),
|
||||
byte(v>>48),
|
||||
byte(v>>40),
|
||||
byte(v>>32),
|
||||
byte(v>>24),
|
||||
byte(v>>16),
|
||||
byte(v>>8),
|
||||
byte(v),
|
||||
)
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
// Copyright 2025 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package hpke
|
||||
|
||||
import (
|
||||
"crypto/hkdf"
|
||||
"crypto/sha256"
|
||||
"crypto/sha3"
|
||||
"crypto/sha512"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
|
||||
"filippo.io/hpke/internal/byteorder"
|
||||
)
|
||||
|
||||
// The KDF is one of the three components of an HPKE ciphersuite, implementing
|
||||
// key derivation.
|
||||
type KDF interface {
|
||||
ID() uint16
|
||||
oneStage() bool
|
||||
size() int // Nh
|
||||
labeledDerive(suiteID, inputKey []byte, label string, context []byte, length uint16) ([]byte, error)
|
||||
labeledExtract(suiteID, salt []byte, label string, inputKey []byte) ([]byte, error)
|
||||
labeledExpand(suiteID, randomKey []byte, label string, info []byte, length uint16) ([]byte, error)
|
||||
}
|
||||
|
||||
// NewKDF returns the KDF implementation for the given KDF ID.
|
||||
//
|
||||
// Applications are encouraged to use specific implementations like [HKDFSHA256]
|
||||
// instead, unless runtime agility is required.
|
||||
func NewKDF(id uint16) (KDF, error) {
|
||||
switch id {
|
||||
case 0x0001: // HKDF-SHA256
|
||||
return HKDFSHA256(), nil
|
||||
case 0x0002: // HKDF-SHA384
|
||||
return HKDFSHA384(), nil
|
||||
case 0x0003: // HKDF-SHA512
|
||||
return HKDFSHA512(), nil
|
||||
case 0x0010: // SHAKE128
|
||||
return SHAKE128(), nil
|
||||
case 0x0011: // SHAKE256
|
||||
return SHAKE256(), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported KDF %04x", id)
|
||||
}
|
||||
}
|
||||
|
||||
// HKDFSHA256 returns an HKDF-SHA256 KDF implementation.
|
||||
func HKDFSHA256() KDF { return hkdfSHA256 }
|
||||
|
||||
// HKDFSHA384 returns an HKDF-SHA384 KDF implementation.
|
||||
func HKDFSHA384() KDF { return hkdfSHA384 }
|
||||
|
||||
// HKDFSHA512 returns an HKDF-SHA512 KDF implementation.
|
||||
func HKDFSHA512() KDF { return hkdfSHA512 }
|
||||
|
||||
type hkdfKDF struct {
|
||||
hash func() hash.Hash
|
||||
id uint16
|
||||
nH int
|
||||
}
|
||||
|
||||
var hkdfSHA256 = &hkdfKDF{hash: sha256.New, id: 0x0001, nH: sha256.Size}
|
||||
var hkdfSHA384 = &hkdfKDF{hash: sha512.New384, id: 0x0002, nH: sha512.Size384}
|
||||
var hkdfSHA512 = &hkdfKDF{hash: sha512.New, id: 0x0003, nH: sha512.Size}
|
||||
|
||||
func (kdf *hkdfKDF) ID() uint16 {
|
||||
return kdf.id
|
||||
}
|
||||
|
||||
func (kdf *hkdfKDF) size() int {
|
||||
return kdf.nH
|
||||
}
|
||||
|
||||
func (kdf *hkdfKDF) oneStage() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (kdf *hkdfKDF) labeledDerive(_, _ []byte, _ string, _ []byte, _ uint16) ([]byte, error) {
|
||||
return nil, errors.New("hpke: internal error: labeledDerive called on two-stage KDF")
|
||||
}
|
||||
|
||||
func (kdf *hkdfKDF) labeledExtract(suiteID []byte, salt []byte, label string, inputKey []byte) ([]byte, error) {
|
||||
labeledIKM := make([]byte, 0, 7+len(suiteID)+len(label)+len(inputKey))
|
||||
labeledIKM = append(labeledIKM, []byte("HPKE-v1")...)
|
||||
labeledIKM = append(labeledIKM, suiteID...)
|
||||
labeledIKM = append(labeledIKM, label...)
|
||||
labeledIKM = append(labeledIKM, inputKey...)
|
||||
return hkdf.Extract(kdf.hash, labeledIKM, salt)
|
||||
}
|
||||
|
||||
func (kdf *hkdfKDF) labeledExpand(suiteID []byte, randomKey []byte, label string, info []byte, length uint16) ([]byte, error) {
|
||||
labeledInfo := make([]byte, 0, 2+7+len(suiteID)+len(label)+len(info))
|
||||
labeledInfo = byteorder.BEAppendUint16(labeledInfo, length)
|
||||
labeledInfo = append(labeledInfo, []byte("HPKE-v1")...)
|
||||
labeledInfo = append(labeledInfo, suiteID...)
|
||||
labeledInfo = append(labeledInfo, label...)
|
||||
labeledInfo = append(labeledInfo, info...)
|
||||
return hkdf.Expand(kdf.hash, randomKey, string(labeledInfo), int(length))
|
||||
}
|
||||
|
||||
// SHAKE128 returns a SHAKE128 KDF implementation.
|
||||
func SHAKE128() KDF {
|
||||
return shake128KDF
|
||||
}
|
||||
|
||||
// SHAKE256 returns a SHAKE256 KDF implementation.
|
||||
func SHAKE256() KDF {
|
||||
return shake256KDF
|
||||
}
|
||||
|
||||
type shakeKDF struct {
|
||||
hash func() *sha3.SHAKE
|
||||
id uint16
|
||||
nH int
|
||||
}
|
||||
|
||||
var shake128KDF = &shakeKDF{hash: sha3.NewSHAKE128, id: 0x0010, nH: 32}
|
||||
var shake256KDF = &shakeKDF{hash: sha3.NewSHAKE256, id: 0x0011, nH: 64}
|
||||
|
||||
func (kdf *shakeKDF) ID() uint16 {
|
||||
return kdf.id
|
||||
}
|
||||
|
||||
func (kdf *shakeKDF) size() int {
|
||||
return kdf.nH
|
||||
}
|
||||
|
||||
func (kdf *shakeKDF) oneStage() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (kdf *shakeKDF) labeledDerive(suiteID, inputKey []byte, label string, context []byte, length uint16) ([]byte, error) {
|
||||
H := kdf.hash()
|
||||
H.Write(inputKey)
|
||||
H.Write([]byte("HPKE-v1"))
|
||||
H.Write(suiteID)
|
||||
H.Write([]byte{byte(len(label) >> 8), byte(len(label))})
|
||||
H.Write([]byte(label))
|
||||
H.Write([]byte{byte(length >> 8), byte(length)})
|
||||
H.Write(context)
|
||||
out := make([]byte, length)
|
||||
H.Read(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (kdf *shakeKDF) labeledExtract(_, _ []byte, _ string, _ []byte) ([]byte, error) {
|
||||
return nil, errors.New("hpke: internal error: labeledExtract called on one-stage KDF")
|
||||
}
|
||||
|
||||
func (kdf *shakeKDF) labeledExpand(_, _ []byte, _ string, _ []byte, _ uint16) ([]byte, error) {
|
||||
return nil, errors.New("hpke: internal error: labeledExpand called on one-stage KDF")
|
||||
}
|
||||
+383
@@ -0,0 +1,383 @@
|
||||
// Copyright 2025 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package hpke
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
|
||||
"filippo.io/hpke/crypto/ecdh"
|
||||
"filippo.io/hpke/internal/byteorder"
|
||||
)
|
||||
|
||||
// A KEM is a Key Encapsulation Mechanism, one of the three components of an
|
||||
// HPKE ciphersuite.
|
||||
type KEM interface {
|
||||
// ID returns the HPKE KEM identifier.
|
||||
ID() uint16
|
||||
|
||||
// GenerateKey generates a new key pair.
|
||||
GenerateKey() (PrivateKey, error)
|
||||
|
||||
// NewPublicKey deserializes a public key from bytes.
|
||||
//
|
||||
// It implements DeserializePublicKey, as defined in RFC 9180.
|
||||
NewPublicKey([]byte) (PublicKey, error)
|
||||
|
||||
// NewPrivateKey deserializes a private key from bytes.
|
||||
//
|
||||
// It implements DeserializePrivateKey, as defined in RFC 9180.
|
||||
NewPrivateKey([]byte) (PrivateKey, error)
|
||||
|
||||
// DeriveKeyPair derives a key pair from the given input keying material.
|
||||
//
|
||||
// It implements DeriveKeyPair, as defined in RFC 9180.
|
||||
DeriveKeyPair(ikm []byte) (PrivateKey, error)
|
||||
|
||||
encSize() int
|
||||
}
|
||||
|
||||
// NewKEM returns the KEM implementation for the given KEM ID.
|
||||
//
|
||||
// Applications are encouraged to use specific implementations like [DHKEM] or
|
||||
// [MLKEM768X25519] instead, unless runtime agility is required.
|
||||
func NewKEM(id uint16) (KEM, error) {
|
||||
switch id {
|
||||
case 0x0010: // DHKEM(P-256, HKDF-SHA256)
|
||||
return DHKEM(ecdh.P256()), nil
|
||||
case 0x0011: // DHKEM(P-384, HKDF-SHA384)
|
||||
return DHKEM(ecdh.P384()), nil
|
||||
case 0x0012: // DHKEM(P-521, HKDF-SHA512)
|
||||
return DHKEM(ecdh.P521()), nil
|
||||
case 0x0020: // DHKEM(X25519, HKDF-SHA256)
|
||||
return DHKEM(ecdh.X25519()), nil
|
||||
case 0x0041: // ML-KEM-768
|
||||
return MLKEM768(), nil
|
||||
case 0x0042: // ML-KEM-1024
|
||||
return MLKEM1024(), nil
|
||||
case 0x647a: // MLKEM768-X25519
|
||||
return MLKEM768X25519(), nil
|
||||
case 0x0050: // MLKEM768-P256
|
||||
return MLKEM768P256(), nil
|
||||
case 0x0051: // MLKEM1024-P384
|
||||
return MLKEM1024P384(), nil
|
||||
default:
|
||||
return nil, errors.New("unsupported KEM")
|
||||
}
|
||||
}
|
||||
|
||||
// A PublicKey is an instantiation of a KEM (one of the three components of an
|
||||
// HPKE ciphersuite) with an encapsulation key (i.e. the public key).
|
||||
//
|
||||
// A PublicKey is usually obtained from a method of the corresponding [KEM] or
|
||||
// [PrivateKey], such as [KEM.NewPublicKey] or [PrivateKey.PublicKey].
|
||||
type PublicKey interface {
|
||||
// KEM returns the instantiated KEM.
|
||||
KEM() KEM
|
||||
|
||||
// Bytes returns the public key as the output of SerializePublicKey.
|
||||
Bytes() []byte
|
||||
|
||||
encap() (sharedSecret, enc []byte, err error)
|
||||
}
|
||||
|
||||
// A PrivateKey is an instantiation of a KEM (one of the three components of
|
||||
// an HPKE ciphersuite) with a decapsulation key (i.e. the secret key).
|
||||
//
|
||||
// A PrivateKey is usually obtained from a method of the corresponding [KEM],
|
||||
// such as [KEM.GenerateKey] or [KEM.NewPrivateKey].
|
||||
type PrivateKey interface {
|
||||
// KEM returns the instantiated KEM.
|
||||
KEM() KEM
|
||||
|
||||
// Bytes returns the private key as the output of SerializePrivateKey, as
|
||||
// defined in RFC 9180.
|
||||
//
|
||||
// Note that for X25519 this might not match the input to NewPrivateKey.
|
||||
// This is a requirement of RFC 9180, Section 7.1.2.
|
||||
Bytes() ([]byte, error)
|
||||
|
||||
// PublicKey returns the corresponding PublicKey.
|
||||
PublicKey() PublicKey
|
||||
|
||||
decap(enc []byte) (sharedSecret []byte, err error)
|
||||
}
|
||||
|
||||
type dhKEM struct {
|
||||
kdf KDF
|
||||
id uint16
|
||||
curve ecdh.Curve
|
||||
Nsecret uint16
|
||||
Nsk uint16
|
||||
Nenc int
|
||||
}
|
||||
|
||||
func (kem *dhKEM) extractAndExpand(dhKey, kemContext []byte) ([]byte, error) {
|
||||
suiteID := byteorder.BEAppendUint16([]byte("KEM"), kem.id)
|
||||
eaePRK, err := kem.kdf.labeledExtract(suiteID, nil, "eae_prk", dhKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return kem.kdf.labeledExpand(suiteID, eaePRK, "shared_secret", kemContext, kem.Nsecret)
|
||||
}
|
||||
|
||||
func (kem *dhKEM) ID() uint16 {
|
||||
return kem.id
|
||||
}
|
||||
|
||||
func (kem *dhKEM) encSize() int {
|
||||
return kem.Nenc
|
||||
}
|
||||
|
||||
var dhKEMP256 = &dhKEM{HKDFSHA256(), 0x0010, ecdh.P256(), 32, 32, 65}
|
||||
var dhKEMP384 = &dhKEM{HKDFSHA384(), 0x0011, ecdh.P384(), 48, 48, 97}
|
||||
var dhKEMP521 = &dhKEM{HKDFSHA512(), 0x0012, ecdh.P521(), 64, 66, 133}
|
||||
var dhKEMX25519 = &dhKEM{HKDFSHA256(), 0x0020, ecdh.X25519(), 32, 32, 32}
|
||||
|
||||
// DHKEM returns a KEM implementing one of
|
||||
//
|
||||
// - DHKEM(P-256, HKDF-SHA256)
|
||||
// - DHKEM(P-384, HKDF-SHA384)
|
||||
// - DHKEM(P-521, HKDF-SHA512)
|
||||
// - DHKEM(X25519, HKDF-SHA256)
|
||||
//
|
||||
// depending on curve.
|
||||
func DHKEM(curve ecdh.Curve) KEM {
|
||||
switch curve {
|
||||
case ecdh.P256():
|
||||
return dhKEMP256
|
||||
case ecdh.P384():
|
||||
return dhKEMP384
|
||||
case ecdh.P521():
|
||||
return dhKEMP521
|
||||
case ecdh.X25519():
|
||||
return dhKEMX25519
|
||||
default:
|
||||
// The set of ecdh.Curve implementations is closed, because the
|
||||
// interface has unexported methods. Therefore, this default case is
|
||||
// only hit if a new curve is added that DHKEM doesn't support.
|
||||
return unsupportedCurveKEM{}
|
||||
}
|
||||
}
|
||||
|
||||
type unsupportedCurveKEM struct{}
|
||||
|
||||
func (unsupportedCurveKEM) ID() uint16 {
|
||||
return 0
|
||||
}
|
||||
func (unsupportedCurveKEM) GenerateKey() (PrivateKey, error) {
|
||||
return nil, errors.New("unsupported curve")
|
||||
}
|
||||
func (unsupportedCurveKEM) NewPublicKey([]byte) (PublicKey, error) {
|
||||
return nil, errors.New("unsupported curve")
|
||||
}
|
||||
func (unsupportedCurveKEM) NewPrivateKey([]byte) (PrivateKey, error) {
|
||||
return nil, errors.New("unsupported curve")
|
||||
}
|
||||
func (unsupportedCurveKEM) DeriveKeyPair([]byte) (PrivateKey, error) {
|
||||
return nil, errors.New("unsupported curve")
|
||||
}
|
||||
func (unsupportedCurveKEM) encSize() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
type dhKEMPublicKey struct {
|
||||
kem *dhKEM
|
||||
pub *ecdh.PublicKey
|
||||
}
|
||||
|
||||
// NewDHKEMPublicKey returns a PublicKey implementing
|
||||
//
|
||||
// - DHKEM(P-256, HKDF-SHA256)
|
||||
// - DHKEM(P-384, HKDF-SHA384)
|
||||
// - DHKEM(P-521, HKDF-SHA512)
|
||||
// - DHKEM(X25519, HKDF-SHA256)
|
||||
//
|
||||
// depending on the underlying curve of pub ([ecdh.X25519], [ecdh.P256],
|
||||
// [ecdh.P384], or [ecdh.P521]).
|
||||
//
|
||||
// This function is meant for applications that already have an instantiated
|
||||
// crypto/ecdh public key. Otherwise, applications should use the
|
||||
// [KEM.NewPublicKey] method of [DHKEM].
|
||||
func NewDHKEMPublicKey(pub *ecdh.PublicKey) (PublicKey, error) {
|
||||
kem, ok := DHKEM(pub.Curve()).(*dhKEM)
|
||||
if !ok {
|
||||
return nil, errors.New("unsupported curve")
|
||||
}
|
||||
return &dhKEMPublicKey{
|
||||
kem: kem,
|
||||
pub: pub,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (kem *dhKEM) NewPublicKey(data []byte) (PublicKey, error) {
|
||||
pub, err := kem.curve.NewPublicKey(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewDHKEMPublicKey(pub)
|
||||
}
|
||||
|
||||
func (pk *dhKEMPublicKey) KEM() KEM {
|
||||
return pk.kem
|
||||
}
|
||||
|
||||
func (pk *dhKEMPublicKey) Bytes() []byte {
|
||||
return pk.pub.Bytes()
|
||||
}
|
||||
|
||||
// testingOnlyGenerateKey is only used during testing, to provide
|
||||
// a fixed test key to use when checking the RFC 9180 vectors.
|
||||
var testingOnlyGenerateKey func() *ecdh.PrivateKey
|
||||
|
||||
func (pk *dhKEMPublicKey) encap() (sharedSecret []byte, encapPub []byte, err error) {
|
||||
privEph, err := pk.pub.Curve().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if testingOnlyGenerateKey != nil {
|
||||
privEph = testingOnlyGenerateKey()
|
||||
}
|
||||
dhVal, err := privEph.ECDH(pk.pub)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
encPubEph := privEph.PublicKey().Bytes()
|
||||
|
||||
encPubRecip := pk.pub.Bytes()
|
||||
kemContext := append(encPubEph, encPubRecip...)
|
||||
sharedSecret, err = pk.kem.extractAndExpand(dhVal, kemContext)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return sharedSecret, encPubEph, nil
|
||||
}
|
||||
|
||||
type dhKEMPrivateKey struct {
|
||||
kem *dhKEM
|
||||
priv ecdh.KeyExchanger
|
||||
}
|
||||
|
||||
// NewDHKEMPrivateKey returns a PrivateKey implementing
|
||||
//
|
||||
// - DHKEM(P-256, HKDF-SHA256)
|
||||
// - DHKEM(P-384, HKDF-SHA384)
|
||||
// - DHKEM(P-521, HKDF-SHA512)
|
||||
// - DHKEM(X25519, HKDF-SHA256)
|
||||
//
|
||||
// depending on the underlying curve of priv ([ecdh.X25519], [ecdh.P256],
|
||||
// [ecdh.P384], or [ecdh.P521]).
|
||||
//
|
||||
// This function is meant for applications that already have an instantiated
|
||||
// crypto/ecdh private key, or another implementation of a [ecdh.KeyExchanger]
|
||||
// (e.g. a hardware key). Otherwise, applications should use the
|
||||
// [KEM.NewPrivateKey] method of [DHKEM].
|
||||
func NewDHKEMPrivateKey(priv ecdh.KeyExchanger) (PrivateKey, error) {
|
||||
kem, ok := DHKEM(priv.Curve()).(*dhKEM)
|
||||
if !ok {
|
||||
return nil, errors.New("unsupported curve")
|
||||
}
|
||||
return &dhKEMPrivateKey{
|
||||
kem: kem,
|
||||
priv: priv,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (kem *dhKEM) GenerateKey() (PrivateKey, error) {
|
||||
priv, err := kem.curve.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewDHKEMPrivateKey(priv)
|
||||
}
|
||||
|
||||
func (kem *dhKEM) NewPrivateKey(ikm []byte) (PrivateKey, error) {
|
||||
priv, err := kem.curve.NewPrivateKey(ikm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewDHKEMPrivateKey(priv)
|
||||
}
|
||||
|
||||
func (kem *dhKEM) DeriveKeyPair(ikm []byte) (PrivateKey, error) {
|
||||
// DeriveKeyPair from RFC 9180 Section 7.1.3.
|
||||
suiteID := byteorder.BEAppendUint16([]byte("KEM"), kem.id)
|
||||
prk, err := kem.kdf.labeledExtract(suiteID, nil, "dkp_prk", ikm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if kem == dhKEMX25519 {
|
||||
s, err := kem.kdf.labeledExpand(suiteID, prk, "sk", nil, kem.Nsk)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return kem.NewPrivateKey(s)
|
||||
}
|
||||
var counter uint8
|
||||
for counter < 4 {
|
||||
s, err := kem.kdf.labeledExpand(suiteID, prk, "candidate", []byte{counter}, kem.Nsk)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if kem == dhKEMP521 {
|
||||
s[0] &= 0x01
|
||||
}
|
||||
r, err := kem.NewPrivateKey(s)
|
||||
if err != nil {
|
||||
counter++
|
||||
continue
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
panic("chance of four rejections is < 2^-128")
|
||||
}
|
||||
|
||||
func (k *dhKEMPrivateKey) KEM() KEM {
|
||||
return k.kem
|
||||
}
|
||||
|
||||
func (k *dhKEMPrivateKey) Bytes() ([]byte, error) {
|
||||
// Bizarrely, RFC 9180, Section 7.1.2 says SerializePrivateKey MUST clamp
|
||||
// the output, which I thought we all agreed to instead do as part of the DH
|
||||
// function, letting private keys be random bytes.
|
||||
//
|
||||
// At the same time, it says DeserializePrivateKey MUST also clamp, implying
|
||||
// that the input doesn't have to be clamped, so Bytes by spec doesn't
|
||||
// necessarily match the NewPrivateKey input.
|
||||
//
|
||||
// I'm sure this will not lead to any unexpected behavior or interop issue.
|
||||
priv, ok := k.priv.(*ecdh.PrivateKey)
|
||||
if !ok {
|
||||
return nil, errors.New("ecdh: private key does not support Bytes")
|
||||
}
|
||||
if k.kem == dhKEMX25519 {
|
||||
b := priv.Bytes()
|
||||
b[0] &= 248
|
||||
b[31] &= 127
|
||||
b[31] |= 64
|
||||
return b, nil
|
||||
}
|
||||
return priv.Bytes(), nil
|
||||
}
|
||||
|
||||
func (k *dhKEMPrivateKey) PublicKey() PublicKey {
|
||||
return &dhKEMPublicKey{
|
||||
kem: k.kem,
|
||||
pub: k.priv.PublicKey(),
|
||||
}
|
||||
}
|
||||
|
||||
func (k *dhKEMPrivateKey) decap(encPubEph []byte) ([]byte, error) {
|
||||
pubEph, err := k.priv.Curve().NewPublicKey(encPubEph)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dhVal, err := k.priv.ECDH(pubEph)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kemContext := append(encPubEph, k.priv.PublicKey().Bytes()...)
|
||||
return k.kem.extractAndExpand(dhVal, kemContext)
|
||||
}
|
||||
+535
@@ -0,0 +1,535 @@
|
||||
// Copyright 2025 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package hpke
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/mlkem"
|
||||
"crypto/rand"
|
||||
"crypto/sha3"
|
||||
"errors"
|
||||
|
||||
"filippo.io/hpke/crypto"
|
||||
"filippo.io/hpke/crypto/ecdh"
|
||||
"filippo.io/hpke/internal/byteorder"
|
||||
)
|
||||
|
||||
var mlkem768X25519 = &hybridKEM{
|
||||
id: 0x647a,
|
||||
label: /**/ `\./` +
|
||||
/* */ `/^\`,
|
||||
curve: ecdh.X25519(),
|
||||
|
||||
curveSeedSize: 32,
|
||||
curvePointSize: 32,
|
||||
pqEncapsKeySize: mlkem.EncapsulationKeySize768,
|
||||
pqCiphertextSize: mlkem.CiphertextSize768,
|
||||
|
||||
pqNewPublicKey: func(data []byte) (crypto.Encapsulator, error) {
|
||||
return mlkem.NewEncapsulationKey768(data)
|
||||
},
|
||||
pqNewPrivateKey: func(data []byte) (crypto.Decapsulator, error) {
|
||||
return wrapDecapsulator(mlkem.NewDecapsulationKey768(data))
|
||||
},
|
||||
}
|
||||
|
||||
// MLKEM768X25519 returns a KEM implementing MLKEM768-X25519 (a.k.a. X-Wing)
|
||||
// from draft-ietf-hpke-pq.
|
||||
func MLKEM768X25519() KEM {
|
||||
return mlkem768X25519
|
||||
}
|
||||
|
||||
var mlkem768P256 = &hybridKEM{
|
||||
id: 0x0050,
|
||||
label: "MLKEM768-P256",
|
||||
curve: ecdh.P256(),
|
||||
|
||||
curveSeedSize: 32,
|
||||
curvePointSize: 65,
|
||||
pqEncapsKeySize: mlkem.EncapsulationKeySize768,
|
||||
pqCiphertextSize: mlkem.CiphertextSize768,
|
||||
|
||||
pqNewPublicKey: func(data []byte) (crypto.Encapsulator, error) {
|
||||
return mlkem.NewEncapsulationKey768(data)
|
||||
},
|
||||
pqNewPrivateKey: func(data []byte) (crypto.Decapsulator, error) {
|
||||
return wrapDecapsulator(mlkem.NewDecapsulationKey768(data))
|
||||
},
|
||||
}
|
||||
|
||||
// MLKEM768P256 returns a KEM implementing MLKEM768-P256 from draft-ietf-hpke-pq.
|
||||
func MLKEM768P256() KEM {
|
||||
return mlkem768P256
|
||||
}
|
||||
|
||||
var mlkem1024P384 = &hybridKEM{
|
||||
id: 0x0051,
|
||||
label: "MLKEM1024-P384",
|
||||
curve: ecdh.P384(),
|
||||
|
||||
curveSeedSize: 48,
|
||||
curvePointSize: 97,
|
||||
pqEncapsKeySize: mlkem.EncapsulationKeySize1024,
|
||||
pqCiphertextSize: mlkem.CiphertextSize1024,
|
||||
|
||||
pqNewPublicKey: func(data []byte) (crypto.Encapsulator, error) {
|
||||
return mlkem.NewEncapsulationKey1024(data)
|
||||
},
|
||||
pqNewPrivateKey: func(data []byte) (crypto.Decapsulator, error) {
|
||||
return wrapDecapsulator(mlkem.NewDecapsulationKey1024(data))
|
||||
},
|
||||
}
|
||||
|
||||
// MLKEM1024P384 returns a KEM implementing MLKEM1024-P384 from draft-ietf-hpke-pq.
|
||||
func MLKEM1024P384() KEM {
|
||||
return mlkem1024P384
|
||||
}
|
||||
|
||||
type hybridKEM struct {
|
||||
id uint16
|
||||
label string
|
||||
curve ecdh.Curve
|
||||
|
||||
curveSeedSize int
|
||||
curvePointSize int
|
||||
pqEncapsKeySize int
|
||||
pqCiphertextSize int
|
||||
|
||||
pqNewPublicKey func(data []byte) (crypto.Encapsulator, error)
|
||||
pqNewPrivateKey func(data []byte) (crypto.Decapsulator, error)
|
||||
}
|
||||
|
||||
func (kem *hybridKEM) ID() uint16 {
|
||||
return kem.id
|
||||
}
|
||||
|
||||
func (kem *hybridKEM) encSize() int {
|
||||
return kem.pqCiphertextSize + kem.curvePointSize
|
||||
}
|
||||
|
||||
func (kem *hybridKEM) sharedSecret(ssPQ, ssT, ctT, ekT []byte) []byte {
|
||||
h := sha3.New256()
|
||||
h.Write(ssPQ)
|
||||
h.Write(ssT)
|
||||
h.Write(ctT)
|
||||
h.Write(ekT)
|
||||
h.Write([]byte(kem.label))
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
type hybridPublicKey struct {
|
||||
kem *hybridKEM
|
||||
t *ecdh.PublicKey
|
||||
pq crypto.Encapsulator
|
||||
}
|
||||
|
||||
// NewHybridPublicKey returns a PublicKey implementing one of
|
||||
//
|
||||
// - MLKEM768-X25519 (a.k.a. X-Wing)
|
||||
// - MLKEM768-P256
|
||||
// - MLKEM1024-P384
|
||||
//
|
||||
// from draft-ietf-hpke-pq, depending on the underlying curve of t
|
||||
// ([ecdh.X25519], [ecdh.P256], or [ecdh.P384]) and the type of pq (either
|
||||
// *[mlkem.EncapsulationKey768] or *[mlkem.EncapsulationKey1024]).
|
||||
//
|
||||
// This function is meant for applications that already have instantiated
|
||||
// crypto/ecdh and crypto/mlkem public keys. Otherwise, applications should use
|
||||
// the [KEM.NewPublicKey] method of e.g. [MLKEM768X25519].
|
||||
func NewHybridPublicKey(pq crypto.Encapsulator, t *ecdh.PublicKey) (PublicKey, error) {
|
||||
switch t.Curve() {
|
||||
case ecdh.X25519():
|
||||
if _, ok := pq.(*mlkem.EncapsulationKey768); !ok {
|
||||
return nil, errors.New("invalid PQ KEM for X25519 hybrid")
|
||||
}
|
||||
return &hybridPublicKey{mlkem768X25519, t, pq}, nil
|
||||
case ecdh.P256():
|
||||
if _, ok := pq.(*mlkem.EncapsulationKey768); !ok {
|
||||
return nil, errors.New("invalid PQ KEM for P-256 hybrid")
|
||||
}
|
||||
return &hybridPublicKey{mlkem768P256, t, pq}, nil
|
||||
case ecdh.P384():
|
||||
if _, ok := pq.(*mlkem.EncapsulationKey1024); !ok {
|
||||
return nil, errors.New("invalid PQ KEM for P-384 hybrid")
|
||||
}
|
||||
return &hybridPublicKey{mlkem1024P384, t, pq}, nil
|
||||
default:
|
||||
return nil, errors.New("unsupported curve")
|
||||
}
|
||||
}
|
||||
|
||||
func (kem *hybridKEM) NewPublicKey(data []byte) (PublicKey, error) {
|
||||
if len(data) != kem.pqEncapsKeySize+kem.curvePointSize {
|
||||
return nil, errors.New("invalid public key size")
|
||||
}
|
||||
pq, err := kem.pqNewPublicKey(data[:kem.pqEncapsKeySize])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k, err := kem.curve.NewPublicKey(data[kem.pqEncapsKeySize:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewHybridPublicKey(pq, k)
|
||||
}
|
||||
|
||||
func (pk *hybridPublicKey) KEM() KEM {
|
||||
return pk.kem
|
||||
}
|
||||
|
||||
func (pk *hybridPublicKey) Bytes() []byte {
|
||||
return append(pk.pq.Bytes(), pk.t.Bytes()...)
|
||||
}
|
||||
|
||||
var testingOnlyEncapsulate func() (ss, ct []byte)
|
||||
|
||||
func (pk *hybridPublicKey) encap() (sharedSecret []byte, encapPub []byte, err error) {
|
||||
skE, err := pk.t.Curve().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if testingOnlyGenerateKey != nil {
|
||||
skE = testingOnlyGenerateKey()
|
||||
}
|
||||
ssT, err := skE.ECDH(pk.t)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
ctT := skE.PublicKey().Bytes()
|
||||
|
||||
ssPQ, ctPQ := pk.pq.Encapsulate()
|
||||
if testingOnlyEncapsulate != nil {
|
||||
ssPQ, ctPQ = testingOnlyEncapsulate()
|
||||
}
|
||||
|
||||
ss := pk.kem.sharedSecret(ssPQ, ssT, ctT, pk.t.Bytes())
|
||||
ct := append(ctPQ, ctT...)
|
||||
return ss, ct, nil
|
||||
}
|
||||
|
||||
type hybridPrivateKey struct {
|
||||
kem *hybridKEM
|
||||
seed []byte // can be nil
|
||||
t ecdh.KeyExchanger
|
||||
pq crypto.Decapsulator
|
||||
}
|
||||
|
||||
// NewHybridPrivateKey returns a PrivateKey implementing
|
||||
//
|
||||
// - MLKEM768-X25519 (a.k.a. X-Wing)
|
||||
// - MLKEM768-P256
|
||||
// - MLKEM1024-P384
|
||||
//
|
||||
// from draft-ietf-hpke-pq, depending on the underlying curve of t
|
||||
// ([ecdh.X25519], [ecdh.P256], or [ecdh.P384]) and the type of pq.Encapsulator()
|
||||
// (either *[mlkem.EncapsulationKey768] or *[mlkem.EncapsulationKey1024]).
|
||||
//
|
||||
// This function is meant for applications that already have instantiated
|
||||
// crypto/ecdh and crypto/mlkem private keys, or another implementation of a
|
||||
// [ecdh.KeyExchanger] and [crypto.Decapsulator] (e.g. a hardware key).
|
||||
// Otherwise, applications should use the [KEM.NewPrivateKey] method of e.g.
|
||||
// [MLKEM768X25519].
|
||||
func NewHybridPrivateKey(pq crypto.Decapsulator, t ecdh.KeyExchanger) (PrivateKey, error) {
|
||||
return newHybridPrivateKey(pq, t, nil)
|
||||
}
|
||||
|
||||
func (kem *hybridKEM) GenerateKey() (PrivateKey, error) {
|
||||
seed := make([]byte, 32)
|
||||
rand.Read(seed)
|
||||
return kem.NewPrivateKey(seed)
|
||||
}
|
||||
|
||||
func (kem *hybridKEM) NewPrivateKey(priv []byte) (PrivateKey, error) {
|
||||
if len(priv) != 32 {
|
||||
return nil, errors.New("hpke: invalid hybrid KEM secret length")
|
||||
}
|
||||
|
||||
s := sha3.NewSHAKE256()
|
||||
s.Write(priv)
|
||||
|
||||
seedPQ := make([]byte, mlkem.SeedSize)
|
||||
s.Read(seedPQ)
|
||||
pq, err := kem.pqNewPrivateKey(seedPQ)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
seedT := make([]byte, kem.curveSeedSize)
|
||||
for {
|
||||
s.Read(seedT)
|
||||
k, err := kem.curve.NewPrivateKey(seedT)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
return newHybridPrivateKey(pq, k, priv)
|
||||
}
|
||||
}
|
||||
|
||||
func newHybridPrivateKey(pq crypto.Decapsulator, t ecdh.KeyExchanger, seed []byte) (PrivateKey, error) {
|
||||
switch t.Curve() {
|
||||
case ecdh.X25519():
|
||||
if _, ok := pq.Encapsulator().(*mlkem.EncapsulationKey768); !ok {
|
||||
return nil, errors.New("invalid PQ KEM for X25519 hybrid")
|
||||
}
|
||||
return &hybridPrivateKey{mlkem768X25519, bytes.Clone(seed), t, pq}, nil
|
||||
case ecdh.P256():
|
||||
if _, ok := pq.Encapsulator().(*mlkem.EncapsulationKey768); !ok {
|
||||
return nil, errors.New("invalid PQ KEM for P-256 hybrid")
|
||||
}
|
||||
return &hybridPrivateKey{mlkem768P256, bytes.Clone(seed), t, pq}, nil
|
||||
case ecdh.P384():
|
||||
if _, ok := pq.Encapsulator().(*mlkem.EncapsulationKey1024); !ok {
|
||||
return nil, errors.New("invalid PQ KEM for P-384 hybrid")
|
||||
}
|
||||
return &hybridPrivateKey{mlkem1024P384, bytes.Clone(seed), t, pq}, nil
|
||||
default:
|
||||
return nil, errors.New("unsupported curve")
|
||||
}
|
||||
}
|
||||
|
||||
func (kem *hybridKEM) DeriveKeyPair(ikm []byte) (PrivateKey, error) {
|
||||
suiteID := byteorder.BEAppendUint16([]byte("KEM"), kem.id)
|
||||
dk, err := SHAKE256().labeledDerive(suiteID, ikm, "DeriveKeyPair", nil, 32)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return kem.NewPrivateKey(dk)
|
||||
}
|
||||
|
||||
func (k *hybridPrivateKey) KEM() KEM {
|
||||
return k.kem
|
||||
}
|
||||
|
||||
func (k *hybridPrivateKey) Bytes() ([]byte, error) {
|
||||
if k.seed == nil {
|
||||
return nil, errors.New("private key seed not available")
|
||||
}
|
||||
return k.seed, nil
|
||||
}
|
||||
|
||||
func (k *hybridPrivateKey) PublicKey() PublicKey {
|
||||
return &hybridPublicKey{
|
||||
kem: k.kem,
|
||||
t: k.t.PublicKey(),
|
||||
pq: k.pq.Encapsulator(),
|
||||
}
|
||||
}
|
||||
|
||||
func (k *hybridPrivateKey) decap(enc []byte) ([]byte, error) {
|
||||
if len(enc) != k.kem.pqCiphertextSize+k.kem.curvePointSize {
|
||||
return nil, errors.New("invalid encapsulated key size")
|
||||
}
|
||||
ctPQ, ctT := enc[:k.kem.pqCiphertextSize], enc[k.kem.pqCiphertextSize:]
|
||||
ssPQ, err := k.pq.Decapsulate(ctPQ)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pub, err := k.t.Curve().NewPublicKey(ctT)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ssT, err := k.t.ECDH(pub)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ss := k.kem.sharedSecret(ssPQ, ssT, ctT, k.t.PublicKey().Bytes())
|
||||
return ss, nil
|
||||
}
|
||||
|
||||
var mlkem768 = &mlkemKEM{
|
||||
id: 0x0041,
|
||||
ciphertextSize: mlkem.CiphertextSize768,
|
||||
newPublicKey: func(data []byte) (crypto.Encapsulator, error) {
|
||||
return mlkem.NewEncapsulationKey768(data)
|
||||
},
|
||||
newPrivateKey: func(data []byte) (crypto.Decapsulator, error) {
|
||||
return wrapDecapsulator(mlkem.NewDecapsulationKey768(data))
|
||||
},
|
||||
generateKey: func() (crypto.Decapsulator, error) {
|
||||
return wrapDecapsulator(mlkem.GenerateKey768())
|
||||
},
|
||||
}
|
||||
|
||||
// MLKEM768 returns a KEM implementing ML-KEM-768 from draft-ietf-hpke-pq.
|
||||
func MLKEM768() KEM {
|
||||
return mlkem768
|
||||
}
|
||||
|
||||
var mlkem1024 = &mlkemKEM{
|
||||
id: 0x0042,
|
||||
ciphertextSize: mlkem.CiphertextSize1024,
|
||||
newPublicKey: func(data []byte) (crypto.Encapsulator, error) {
|
||||
return mlkem.NewEncapsulationKey1024(data)
|
||||
},
|
||||
newPrivateKey: func(data []byte) (crypto.Decapsulator, error) {
|
||||
return wrapDecapsulator(mlkem.NewDecapsulationKey1024(data))
|
||||
},
|
||||
generateKey: func() (crypto.Decapsulator, error) {
|
||||
return wrapDecapsulator(mlkem.GenerateKey1024())
|
||||
},
|
||||
}
|
||||
|
||||
// MLKEM1024 returns a KEM implementing ML-KEM-1024 from draft-ietf-hpke-pq.
|
||||
func MLKEM1024() KEM {
|
||||
return mlkem1024
|
||||
}
|
||||
|
||||
type mlkemKEM struct {
|
||||
id uint16
|
||||
ciphertextSize int
|
||||
newPublicKey func(data []byte) (crypto.Encapsulator, error)
|
||||
newPrivateKey func(data []byte) (crypto.Decapsulator, error)
|
||||
generateKey func() (crypto.Decapsulator, error)
|
||||
}
|
||||
|
||||
func (kem *mlkemKEM) ID() uint16 {
|
||||
return kem.id
|
||||
}
|
||||
|
||||
func (kem *mlkemKEM) encSize() int {
|
||||
return kem.ciphertextSize
|
||||
}
|
||||
|
||||
type mlkemPublicKey struct {
|
||||
kem *mlkemKEM
|
||||
pq crypto.Encapsulator
|
||||
}
|
||||
|
||||
// NewMLKEMPublicKey returns a KEMPublicKey implementing
|
||||
//
|
||||
// - ML-KEM-768
|
||||
// - ML-KEM-1024
|
||||
//
|
||||
// from draft-ietf-hpke-pq, depending on the type of pub
|
||||
// (*[mlkem.EncapsulationKey768] or *[mlkem.EncapsulationKey1024]).
|
||||
//
|
||||
// This function is meant for applications that already have an instantiated
|
||||
// crypto/mlkem public key. Otherwise, applications should use the
|
||||
// [KEM.NewPublicKey] method of e.g. [MLKEM768].
|
||||
func NewMLKEMPublicKey(pub crypto.Encapsulator) (PublicKey, error) {
|
||||
switch pub.(type) {
|
||||
case *mlkem.EncapsulationKey768:
|
||||
return &mlkemPublicKey{mlkem768, pub}, nil
|
||||
case *mlkem.EncapsulationKey1024:
|
||||
return &mlkemPublicKey{mlkem1024, pub}, nil
|
||||
default:
|
||||
return nil, errors.New("unsupported public key type")
|
||||
}
|
||||
}
|
||||
|
||||
func (kem *mlkemKEM) NewPublicKey(data []byte) (PublicKey, error) {
|
||||
pq, err := kem.newPublicKey(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewMLKEMPublicKey(pq)
|
||||
}
|
||||
|
||||
func (pk *mlkemPublicKey) KEM() KEM {
|
||||
return pk.kem
|
||||
}
|
||||
|
||||
func (pk *mlkemPublicKey) Bytes() []byte {
|
||||
return pk.pq.Bytes()
|
||||
}
|
||||
|
||||
func (pk *mlkemPublicKey) encap() (sharedSecret []byte, encapPub []byte, err error) {
|
||||
ss, ct := pk.pq.Encapsulate()
|
||||
if testingOnlyEncapsulate != nil {
|
||||
ss, ct = testingOnlyEncapsulate()
|
||||
}
|
||||
return ss, ct, nil
|
||||
}
|
||||
|
||||
type mlkemPrivateKey struct {
|
||||
kem *mlkemKEM
|
||||
pq crypto.Decapsulator
|
||||
}
|
||||
|
||||
// NewMLKEMPrivateKey returns a KEMPrivateKey implementing
|
||||
//
|
||||
// - ML-KEM-768
|
||||
// - ML-KEM-1024
|
||||
//
|
||||
// from draft-ietf-hpke-pq, depending on the type of priv.Encapsulator()
|
||||
// (either *[mlkem.EncapsulationKey768] or *[mlkem.EncapsulationKey1024]).
|
||||
//
|
||||
// This function is meant for applications that already have an instantiated
|
||||
// crypto/mlkem private key. Otherwise, applications should use the
|
||||
// [KEM.NewPrivateKey] method of e.g. [MLKEM768].
|
||||
func NewMLKEMPrivateKey(priv crypto.Decapsulator) (PrivateKey, error) {
|
||||
switch priv.Encapsulator().(type) {
|
||||
case *mlkem.EncapsulationKey768:
|
||||
return &mlkemPrivateKey{mlkem768, priv}, nil
|
||||
case *mlkem.EncapsulationKey1024:
|
||||
return &mlkemPrivateKey{mlkem1024, priv}, nil
|
||||
default:
|
||||
return nil, errors.New("unsupported public key type")
|
||||
}
|
||||
}
|
||||
|
||||
func (kem *mlkemKEM) GenerateKey() (PrivateKey, error) {
|
||||
pq, err := kem.generateKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewMLKEMPrivateKey(pq)
|
||||
}
|
||||
|
||||
func (kem *mlkemKEM) NewPrivateKey(priv []byte) (PrivateKey, error) {
|
||||
pq, err := kem.newPrivateKey(priv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewMLKEMPrivateKey(pq)
|
||||
}
|
||||
|
||||
func (kem *mlkemKEM) DeriveKeyPair(ikm []byte) (PrivateKey, error) {
|
||||
suiteID := byteorder.BEAppendUint16([]byte("KEM"), kem.id)
|
||||
dk, err := SHAKE256().labeledDerive(suiteID, ikm, "DeriveKeyPair", nil, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return kem.NewPrivateKey(dk)
|
||||
}
|
||||
|
||||
func (k *mlkemPrivateKey) KEM() KEM {
|
||||
return k.kem
|
||||
}
|
||||
|
||||
func (k *mlkemPrivateKey) Bytes() ([]byte, error) {
|
||||
pq, ok := k.pq.(interface {
|
||||
Bytes() []byte
|
||||
})
|
||||
if !ok {
|
||||
return nil, errors.New("private key seed not available")
|
||||
}
|
||||
return pq.Bytes(), nil
|
||||
}
|
||||
|
||||
func (k *mlkemPrivateKey) PublicKey() PublicKey {
|
||||
return &mlkemPublicKey{
|
||||
kem: k.kem,
|
||||
pq: k.pq.Encapsulator(),
|
||||
}
|
||||
}
|
||||
|
||||
func (k *mlkemPrivateKey) decap(enc []byte) ([]byte, error) {
|
||||
return k.pq.Decapsulate(enc)
|
||||
}
|
||||
|
||||
func wrapDecapsulator(dk any, err error) (crypto.Decapsulator, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch key := dk.(type) {
|
||||
case *mlkem.DecapsulationKey768:
|
||||
return crypto.DecapsulatorFromDecapsulationKey768(key), nil
|
||||
case *mlkem.DecapsulationKey1024:
|
||||
return crypto.DecapsulatorFromDecapsulationKey1024(key), nil
|
||||
default:
|
||||
return nil, errors.New("hpke: internal error: unknown decapsulation key type")
|
||||
}
|
||||
}
|
||||
+879
-1850
File diff suppressed because it is too large.
Load diff
+796
-1572
File diff suppressed because it is too large.
Load diff
+26
@@ -0,0 +1,26 @@
|
||||
Copyright (C) 2022, Michael J. Fromberger
|
||||
All Rights Reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
(1) Redistributions of source code must retain the above copyright notice,
|
||||
this list of conditions and the following disclaimer.
|
||||
|
||||
(2) Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
|
||||
(3) The name of the author may not be used to endorse or promote products
|
||||
derived from this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE AUTHOR "AS IS" AND ANY EXPRESS OR IMPLIED
|
||||
WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO
|
||||
EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
||||
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT
|
||||
OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING
|
||||
IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY
|
||||
OF SUCH DAMAGE.
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
// Package trigger implements channel-based condition variable.
|
||||
package trigger
|
||||
|
||||
import "sync"
|
||||
|
||||
// A Cond is a condition shared by multiple goroutines. The [Cond.Ready]
|
||||
// method returns a channel that is closed when the condition is activated.
|
||||
//
|
||||
// When a condition is first created it is inactive. While inactive, reads on
|
||||
// the ready channel will block. The condition remains inactive until
|
||||
// [Cond.Set] or [Cond.Signal] is called, either of which causes the current
|
||||
// ready channel to be closed (and thus deliver a zero value). Once a condition
|
||||
// has been activated, it remains active until it is reset. Use [Cond.Reset] to
|
||||
// make it inactive again.
|
||||
//
|
||||
// The [Cond.Signal] method activates and then immediately resets the
|
||||
// condition, acting as Set and Reset done in a single step.
|
||||
//
|
||||
// A zero Cond is ready for use, and is inactive, but must not be copied after
|
||||
// any of its methods have been called.
|
||||
type Cond struct {
|
||||
μ sync.Mutex
|
||||
ch chan struct{}
|
||||
closed bool
|
||||
|
||||
// The signal channel is lazily initialized by the first waiter.
|
||||
}
|
||||
|
||||
// New constructs a new inactive [Cond].
|
||||
func New() *Cond { return new(Cond) }
|
||||
|
||||
// Signal activates and immediately resets the condition. If the condition was
|
||||
// already active, this is equivalent to [Cond.Reset].
|
||||
func (c *Cond) Signal() {
|
||||
c.μ.Lock()
|
||||
defer c.μ.Unlock()
|
||||
|
||||
if c.ch != nil && !c.closed {
|
||||
close(c.ch) // wake any pending waiters
|
||||
}
|
||||
c.ch = nil
|
||||
c.closed = false
|
||||
}
|
||||
|
||||
// Set activates the condition. If it was already active, Set has no effect.
|
||||
func (c *Cond) Set() {
|
||||
c.μ.Lock()
|
||||
defer c.μ.Unlock()
|
||||
|
||||
if c.ch == nil {
|
||||
c.ch = make(chan struct{})
|
||||
}
|
||||
if !c.closed {
|
||||
close(c.ch)
|
||||
c.closed = true
|
||||
}
|
||||
}
|
||||
|
||||
// Reset resets the condition. If it was already inactive, Reset has no effect.
|
||||
func (c *Cond) Reset() {
|
||||
c.μ.Lock()
|
||||
defer c.μ.Unlock()
|
||||
|
||||
if c.closed {
|
||||
c.ch = nil
|
||||
c.closed = false
|
||||
}
|
||||
}
|
||||
|
||||
// Ready returns a channel that is closed when c is activated. If c is active
|
||||
// when Ready is called, the returned channel will already be closed.
|
||||
func (c *Cond) Ready() <-chan struct{} {
|
||||
c.μ.Lock()
|
||||
defer c.μ.Unlock()
|
||||
|
||||
if c.ch == nil {
|
||||
c.ch = make(chan struct{})
|
||||
c.closed = false
|
||||
}
|
||||
return c.ch
|
||||
}
|
||||
+650
@@ -0,0 +1,650 @@
|
||||
# Examples
|
||||
|
||||
All the examples below are going to start with the following block, although
|
||||
nothing except a single Row is mandatory for the `Render()` function to render
|
||||
something:
|
||||
```golang
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/jedib0t/go-pretty/v6/table"
|
||||
)
|
||||
|
||||
func main() {
|
||||
t := table.NewWriter()
|
||||
t.SetOutputMirror(os.Stdout)
|
||||
t.AppendHeader(table.Row{"#", "First Name", "Last Name", "Salary"})
|
||||
t.AppendRows([]table.Row{
|
||||
{1, "Arya", "Stark", 3000},
|
||||
{20, "Jon", "Snow", 2000, "You know nothing, Jon Snow!"},
|
||||
})
|
||||
t.AppendSeparator()
|
||||
t.AppendRow([]interface{}{300, "Tyrion", "Lannister", 5000})
|
||||
t.AppendFooter(table.Row{"", "", "Total", 10000})
|
||||
t.Render()
|
||||
}
|
||||
```
|
||||
Running the above will result in:
|
||||
```
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| # | FIRST NAME | LAST NAME | SALARY | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| 1 | Arya | Stark | 3000 | |
|
||||
| 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| 300 | Tyrion | Lannister | 5000 | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| | | TOTAL | 10000 | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><strong>Ready-to-use Styles</strong></summary>
|
||||
|
||||
Table comes with a bunch of ready-to-use Styles that make the table look really
|
||||
good. Set or Change the style using:
|
||||
```golang
|
||||
t.SetStyle(table.StyleLight)
|
||||
t.Render()
|
||||
```
|
||||
to get:
|
||||
```
|
||||
┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐
|
||||
│ # │ FIRST NAME │ LAST NAME │ SALARY │ │
|
||||
├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
|
||||
│ 1 │ Arya │ Stark │ 3000 │ │
|
||||
│ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │
|
||||
├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
|
||||
│ 300 │ Tyrion │ Lannister │ 5000 │ │
|
||||
├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
|
||||
│ │ │ TOTAL │ 10000 │ │
|
||||
└─────┴────────────┴───────────┴────────┴─────────────────────────────┘
|
||||
```
|
||||
|
||||
Or if you want to use a full-color mode, and don't care for boxes, use:
|
||||
```golang
|
||||
t.SetStyle(table.StyleColoredBright)
|
||||
t.Render()
|
||||
```
|
||||
to get:
|
||||
|
||||
<img src="images/table-StyleColoredBright.png" width="640px" alt="Colored Table"/>
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><strong>Roll your own Style</strong></summary>
|
||||
|
||||
You can also roll your own style:
|
||||
```golang
|
||||
t.SetStyle(table.Style{
|
||||
Name: "myNewStyle",
|
||||
Box: table.BoxStyle{
|
||||
BottomLeft: "\\",
|
||||
BottomRight: "/",
|
||||
BottomSeparator: "v",
|
||||
Left: "[",
|
||||
LeftSeparator: "{",
|
||||
MiddleHorizontal: "-",
|
||||
MiddleSeparator: "+",
|
||||
MiddleVertical: "|",
|
||||
PaddingLeft: "<",
|
||||
PaddingRight: ">",
|
||||
Right: "]",
|
||||
RightSeparator: "}",
|
||||
TopLeft: "(",
|
||||
TopRight: ")",
|
||||
TopSeparator: "^",
|
||||
UnfinishedRow: " ~~~",
|
||||
},
|
||||
Color: table.ColorOptions{
|
||||
IndexColumn: text.Colors{text.BgCyan, text.FgBlack},
|
||||
Footer: text.Colors{text.BgCyan, text.FgBlack},
|
||||
Header: text.Colors{text.BgHiCyan, text.FgBlack},
|
||||
Row: text.Colors{text.BgHiWhite, text.FgBlack},
|
||||
RowAlternate: text.Colors{text.BgWhite, text.FgBlack},
|
||||
},
|
||||
Format: table.FormatOptions{
|
||||
Footer: text.FormatUpper,
|
||||
Header: text.FormatUpper,
|
||||
Row: text.FormatDefault,
|
||||
},
|
||||
Options: table.Options{
|
||||
DrawBorder: true,
|
||||
SeparateColumns: true,
|
||||
SeparateFooter: true,
|
||||
SeparateHeader: true,
|
||||
SeparateRows: false,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Or you can use one of the ready-to-use Styles, and just make a few tweaks:
|
||||
```golang
|
||||
t.SetStyle(table.StyleLight)
|
||||
t.Style().Color.Header = text.Colors{text.BgHiCyan, text.FgBlack}
|
||||
t.Style().Color.IndexColumn = text.Colors{text.BgHiCyan, text.FgBlack}
|
||||
t.Style().Format.Footer = text.FormatLower
|
||||
t.Style().Options.DrawBorder = false
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><strong>Customize Horizontal Separators</strong></summary>
|
||||
|
||||
For more granular control over horizontal lines in your table, you can use `BoxStyleHorizontal` to customize different separator types independently. This allows you to have different horizontal line styles for titles, headers, rows, and footers.
|
||||
|
||||
The `BoxStyleHorizontal` struct provides 10 customizable separator types:
|
||||
- `TitleTop` / `TitleBottom` - Lines above/below the title
|
||||
- `HeaderTop` / `HeaderMiddle` / `HeaderBottom` - Lines in the header section
|
||||
- `RowTop` / `RowMiddle` / `RowBottom` - Lines in the data rows section
|
||||
- `FooterTop` / `FooterMiddle` / `FooterBottom` - Lines in the footer section
|
||||
|
||||
You can customize each separator type using:
|
||||
|
||||
```golang
|
||||
tw := table.NewWriter()
|
||||
tw.AppendHeader(table.Row{"#", "First Name", "Last Name", "Salary"})
|
||||
tw.AppendRows([]table.Row{
|
||||
{1, "Arya", "Stark", 3000},
|
||||
{20, "Jon", "Snow", 2000, "You know nothing, Jon Snow!"},
|
||||
{300, "Tyrion", "Lannister", 5000},
|
||||
})
|
||||
tw.AppendFooter(table.Row{"", "", "Total", 10000})
|
||||
tw.SetStyle(table.StyleDefault)
|
||||
tw.Style().Box.Horizontal = &table.BoxStyleHorizontal{
|
||||
HeaderTop: "=", // Thicker line above header
|
||||
HeaderMiddle: "-",
|
||||
HeaderBottom: "~", // Thicker line below header
|
||||
RowTop: "-",
|
||||
RowMiddle: "- ",
|
||||
RowBottom: "-",
|
||||
FooterTop: "~", // Thicker line above footer
|
||||
FooterMiddle: "-",
|
||||
FooterBottom: "=", // Thicker line below footer
|
||||
}
|
||||
tw.Style().Options.SeparateRows = true
|
||||
fmt.Println(tw.Render())
|
||||
```
|
||||
to get something like:
|
||||
```
|
||||
+=====+============+===========+========+=============================+
|
||||
| # | FIRST NAME | LAST NAME | SALARY | |
|
||||
+~~~~~+~~~~~~~~~~~~+~~~~~~~~~~~+~~~~~~~~+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+
|
||||
| 1 | Arya | Stark | 3000 | |
|
||||
+- - -+- - - - - - +- - - - - -+- - - - +- - - - - - - - - - - - - - -+
|
||||
| 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! |
|
||||
+- - -+- - - - - - +- - - - - -+- - - - +- - - - - - - - - - - - - - -+
|
||||
| 300 | Tyrion | Lannister | 5000 | |
|
||||
+~~~~~+~~~~~~~~~~~~+~~~~~~~~~~~+~~~~~~~~+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+
|
||||
| | | TOTAL | 10000 | |
|
||||
+=====+============+===========+========+=============================+
|
||||
```
|
||||
|
||||
When `BoxStyle.Horizontal` is set to a non-nil value, it overrides the `MiddleHorizontal` string for all horizontal separators. If `Horizontal` is nil, the table will automatically use `MiddleHorizontal` for all separator types.
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><strong>Auto-Merge</strong></summary>
|
||||
|
||||
You can auto-merge cells horizontally and vertically, but you have request for
|
||||
it specifically for each row/column using `RowConfig` or `ColumnConfig`.
|
||||
|
||||
```golang
|
||||
rowConfigAutoMerge := table.RowConfig{AutoMerge: true}
|
||||
|
||||
t := table.NewWriter()
|
||||
t.AppendHeader(table.Row{"Node IP", "Pods", "Namespace", "Container", "RCE", "RCE"}, rowConfigAutoMerge)
|
||||
t.AppendHeader(table.Row{"Node IP", "Pods", "Namespace", "Container", "EXE", "RUN"})
|
||||
t.AppendRow(table.Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 1", "Y", "Y"}, rowConfigAutoMerge)
|
||||
t.AppendRow(table.Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 2", "Y", "N"}, rowConfigAutoMerge)
|
||||
t.AppendRow(table.Row{"1.1.1.1", "Pod 1A", "NS 1B", "C 3", "N", "N"}, rowConfigAutoMerge)
|
||||
t.AppendRow(table.Row{"1.1.1.1", "Pod 1B", "NS 2", "C 4", "N", "N"}, rowConfigAutoMerge)
|
||||
t.AppendRow(table.Row{"1.1.1.1", "Pod 1B", "NS 2", "C 5", "Y", "N"}, rowConfigAutoMerge)
|
||||
t.AppendRow(table.Row{"2.2.2.2", "Pod 2", "NS 3", "C 6", "Y", "Y"}, rowConfigAutoMerge)
|
||||
t.AppendRow(table.Row{"2.2.2.2", "Pod 2", "NS 3", "C 7", "Y", "Y"}, rowConfigAutoMerge)
|
||||
t.AppendFooter(table.Row{"", "", "", 7, 5, 3})
|
||||
t.SetAutoIndex(true)
|
||||
t.SetColumnConfigs([]table.ColumnConfig{
|
||||
{Number: 1, AutoMerge: true},
|
||||
{Number: 2, AutoMerge: true},
|
||||
{Number: 3, AutoMerge: true},
|
||||
{Number: 4, AutoMerge: true},
|
||||
{Number: 5, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter},
|
||||
{Number: 6, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter},
|
||||
})
|
||||
t.SetStyle(table.StyleLight)
|
||||
t.Style().Options.SeparateRows = true
|
||||
fmt.Println(t.Render())
|
||||
```
|
||||
to get:
|
||||
```
|
||||
┌───┬─────────┬────────┬───────────┬───────────┬───────────┐
|
||||
│ │ NODE IP │ PODS │ NAMESPACE │ CONTAINER │ RCE │
|
||||
│ │ │ │ │ ├─────┬─────┤
|
||||
│ │ │ │ │ │ EXE │ RUN │
|
||||
├───┼─────────┼────────┼───────────┼───────────┼─────┴─────┤
|
||||
│ 1 │ 1.1.1.1 │ Pod 1A │ NS 1A │ C 1 │ Y │
|
||||
├───┤ │ │ ├───────────┼─────┬─────┤
|
||||
│ 2 │ │ │ │ C 2 │ Y │ N │
|
||||
├───┤ │ ├───────────┼───────────┼─────┴─────┤
|
||||
│ 3 │ │ │ NS 1B │ C 3 │ N │
|
||||
├───┤ ├────────┼───────────┼───────────┼───────────┤
|
||||
│ 4 │ │ Pod 1B │ NS 2 │ C 4 │ N │
|
||||
├───┤ │ │ ├───────────┼─────┬─────┤
|
||||
│ 5 │ │ │ │ C 5 │ Y │ N │
|
||||
├───┼─────────┼────────┼───────────┼───────────┼─────┴─────┤
|
||||
│ 6 │ 2.2.2.2 │ Pod 2 │ NS 3 │ C 6 │ Y │
|
||||
├───┤ │ │ ├───────────┼───────────┤
|
||||
│ 7 │ │ │ │ C 7 │ Y │
|
||||
├───┼─────────┼────────┼───────────┼───────────┼─────┬─────┤
|
||||
│ │ │ │ │ 7 │ 5 │ 3 │
|
||||
└───┴─────────┴────────┴───────────┴───────────┴─────┴─────┘
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><strong>Paging</strong></summary>
|
||||
|
||||
You can limit the number of lines rendered in a single "Page". This logic
|
||||
can handle rows with multiple lines too. The recommended way is to use the
|
||||
`Pager()` interface:
|
||||
|
||||
```golang
|
||||
pager := t.Pager(PageSize(1))
|
||||
pager.Render() // Render first page
|
||||
pager.Next() // Move to next page and render
|
||||
pager.Prev() // Move to previous page and render
|
||||
pager.GoTo(3) // Jump to page 3
|
||||
pager.Location() // Get current page number
|
||||
```
|
||||
|
||||
Or use the deprecated `SetPageSize()` method for simple cases:
|
||||
```golang
|
||||
t.SetPageSize(1)
|
||||
t.Render()
|
||||
```
|
||||
to get:
|
||||
```
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| # | FIRST NAME | LAST NAME | SALARY | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| 1 | Arya | Stark | 3000 | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| | | TOTAL | 10000 | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| # | FIRST NAME | LAST NAME | SALARY | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| | | TOTAL | 10000 | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| # | FIRST NAME | LAST NAME | SALARY | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| 300 | Tyrion | Lannister | 5000 | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| | | TOTAL | 10000 | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><strong>Filtering</strong></summary>
|
||||
|
||||
Filtering can be done on one or more columns. All filters are applied with AND logic (all must match).
|
||||
Filters are applied before sorting.
|
||||
|
||||
```golang
|
||||
t.FilterBy([]table.FilterBy{
|
||||
{Name: "Salary", Operator: table.GreaterThan, Value: 2000},
|
||||
{Name: "First Name", Operator: table.Contains, Value: "on"},
|
||||
})
|
||||
```
|
||||
|
||||
The `Operator` field in `FilterBy` supports various filtering operators:
|
||||
- `Equal` / `NotEqual` - Exact match
|
||||
- `GreaterThan` / `GreaterThanOrEqual` - Numeric comparisons
|
||||
- `LessThan` / `LessThanOrEqual` - Numeric comparisons
|
||||
- `Contains` / `NotContains` - String search
|
||||
- `StartsWith` / `EndsWith` - String prefix/suffix matching
|
||||
- `RegexMatch` / `RegexNotMatch` - Regular expression matching
|
||||
|
||||
You can make string comparisons case-insensitive by setting `IgnoreCase: true`:
|
||||
```golang
|
||||
t.FilterBy([]table.FilterBy{
|
||||
{Name: "First Name", Operator: table.Equal, Value: "JON", IgnoreCase: true},
|
||||
})
|
||||
```
|
||||
|
||||
For advanced filtering requirements, you can provide a custom filter function:
|
||||
```golang
|
||||
t.FilterBy([]table.FilterBy{
|
||||
{
|
||||
Number: 2,
|
||||
CustomFilter: func(cellValue string) bool {
|
||||
// Custom logic: include rows where first name length > 3
|
||||
return len(cellValue) > 3
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Example: Filter by salary and name
|
||||
```golang
|
||||
t := table.NewWriter()
|
||||
t.AppendHeader(table.Row{"#", "First Name", "Last Name", "Salary"})
|
||||
t.AppendRows([]table.Row{
|
||||
{1, "Arya", "Stark", 3000},
|
||||
{20, "Jon", "Snow", 2000},
|
||||
{300, "Tyrion", "Lannister", 5000},
|
||||
{400, "Sansa", "Stark", 2500},
|
||||
})
|
||||
t.FilterBy([]table.FilterBy{
|
||||
{Number: 4, Operator: table.GreaterThan, Value: 2000},
|
||||
{Number: 3, Operator: table.Contains, Value: "Stark"},
|
||||
})
|
||||
t.Render()
|
||||
```
|
||||
to get:
|
||||
```
|
||||
+-----+------------+-----------+--------+
|
||||
| # | FIRST NAME | LAST NAME | SALARY |
|
||||
+-----+------------+-----------+--------+
|
||||
| 1 | Arya | Stark | 3000 |
|
||||
| 400 | Sansa | Stark | 2500 |
|
||||
+-----+------------+-----------+--------+
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><strong>Sorting</strong></summary>
|
||||
|
||||
Sorting can be done on one or more columns. The following code will make the
|
||||
rows be sorted first by "First Name" and then by "Last Name" (in case of similar
|
||||
"First Name" entries).
|
||||
```golang
|
||||
t.SortBy([]table.SortBy{
|
||||
{Name: "First Name", Mode: table.Asc},
|
||||
{Name: "Last Name", Mode: table.Asc},
|
||||
})
|
||||
```
|
||||
|
||||
The `Mode` field in `SortBy` supports various sorting modes:
|
||||
- `Asc` / `Dsc` - Alphabetical ascending/descending
|
||||
- `AscNumeric` / `DscNumeric` - Numerical ascending/descending
|
||||
- `AscAlphaNumeric` / `DscAlphaNumeric` - Alphabetical first, then numerical
|
||||
- `AscNumericAlpha` / `DscNumericAlpha` - Numerical first, then alphabetical
|
||||
|
||||
You can also make sorting case-insensitive by setting `IgnoreCase: true`:
|
||||
```golang
|
||||
t.SortBy([]table.SortBy{
|
||||
{Name: "First Name", Mode: table.Asc, IgnoreCase: true},
|
||||
})
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><strong>Sorting Customization</strong></summary>
|
||||
|
||||
For advanced sorting requirements, you can provide a custom comparison function
|
||||
using `CustomLess`. This function overrides the `Mode` and `IgnoreCase` settings
|
||||
and gives you full control over the sorting logic.
|
||||
|
||||
The `CustomLess` function receives two string values (the cell contents converted
|
||||
to strings) and must return:
|
||||
- `-1` when the first value should come before the second
|
||||
- `0` when the values are considered equal (sorting continues to the next column)
|
||||
- `1` when the first value should come after the second
|
||||
|
||||
Example: Custom numeric sorting that handles string numbers correctly.
|
||||
```golang
|
||||
t.SortBy([]table.SortBy{
|
||||
{
|
||||
Number: 1,
|
||||
CustomLess: func(iStr string, jStr string) int {
|
||||
iNum, iErr := strconv.Atoi(iStr)
|
||||
jNum, jErr := strconv.Atoi(jStr)
|
||||
if iErr != nil || jErr != nil {
|
||||
// Fallback to string comparison if not numeric
|
||||
if iStr < jStr {
|
||||
return -1
|
||||
}
|
||||
if iStr > jStr {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
if iNum < jNum {
|
||||
return -1
|
||||
}
|
||||
if iNum > jNum {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Example: Combining custom sorting with default sorting modes.
|
||||
```golang
|
||||
t.SortBy([]table.SortBy{
|
||||
{
|
||||
Number: 1,
|
||||
CustomLess: func(iStr string, jStr string) int {
|
||||
// Custom logic: "same" values come first
|
||||
if iStr == "same" && jStr != "same" {
|
||||
return -1
|
||||
}
|
||||
if iStr != "same" && jStr == "same" {
|
||||
return 1
|
||||
}
|
||||
return 0 // Equal, continue to next column
|
||||
},
|
||||
},
|
||||
{Number: 2, Mode: table.Asc}, // Default alphabetical sort
|
||||
{Number: 3, Mode: table.AscNumeric}, // Default numeric sort
|
||||
})
|
||||
```
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><strong>Wrapping (or) Row/Column Width restrictions</strong></summary>
|
||||
|
||||
You can restrict the maximum (text) width for a Row:
|
||||
```golang
|
||||
t.SetAllowedRowLength(50)
|
||||
t.Render()
|
||||
```
|
||||
to get:
|
||||
```
|
||||
+-----+------------+-----------+--------+------- ~
|
||||
| # | FIRST NAME | LAST NAME | SALARY | ~
|
||||
+-----+------------+-----------+--------+------- ~
|
||||
| 1 | Arya | Stark | 3000 | ~
|
||||
| 20 | Jon | Snow | 2000 | You kn ~
|
||||
+-----+------------+-----------+--------+------- ~
|
||||
| 300 | Tyrion | Lannister | 5000 | ~
|
||||
+-----+------------+-----------+--------+------- ~
|
||||
| | | TOTAL | 10000 | ~
|
||||
+-----+------------+-----------+--------+------- ~
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><strong>Column Control - Alignment, Colors, Width and more</strong></summary>
|
||||
|
||||
You can control a lot of things about individual cells/columns which overrides
|
||||
global properties/styles using the `SetColumnConfig()` interface:
|
||||
- Alignment (horizontal & vertical)
|
||||
- Colorization
|
||||
- Transform individual cells based on the content
|
||||
- Visibility
|
||||
- Width (minimum & maximum)
|
||||
|
||||
```golang
|
||||
nameTransformer := text.Transformer(func(val interface{}) string {
|
||||
return text.Bold.Sprint(val)
|
||||
})
|
||||
|
||||
t.SetColumnConfigs([]ColumnConfig{
|
||||
{
|
||||
Name: "First Name",
|
||||
Align: text.AlignLeft,
|
||||
AlignFooter: text.AlignLeft,
|
||||
AlignHeader: text.AlignLeft,
|
||||
Colors: text.Colors{text.BgBlack, text.FgRed},
|
||||
ColorsHeader: text.Colors{text.BgRed, text.FgBlack, text.Bold},
|
||||
ColorsFooter: text.Colors{text.BgRed, text.FgBlack},
|
||||
Hidden: false,
|
||||
Transformer: nameTransformer,
|
||||
TransformerFooter: nameTransformer,
|
||||
TransformerHeader: nameTransformer,
|
||||
VAlign: text.VAlignMiddle,
|
||||
VAlignFooter: text.VAlignTop,
|
||||
VAlignHeader: text.VAlignBottom,
|
||||
WidthMin: 6,
|
||||
WidthMax: 64,
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><strong>CSV</strong></summary>
|
||||
|
||||
```golang
|
||||
t.RenderCSV()
|
||||
```
|
||||
to get:
|
||||
```
|
||||
,First Name,Last Name,Salary,
|
||||
1,Arya,Stark,3000,
|
||||
20,Jon,Snow,2000,"You know nothing\, Jon Snow!"
|
||||
300,Tyrion,Lannister,5000,
|
||||
,,Total,10000,
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><strong>HTML Table</strong></summary>
|
||||
|
||||
```golang
|
||||
t.Style().HTML = table.HTMLOptions{
|
||||
CSSClass: "game-of-thrones",
|
||||
EmptyColumn: " ",
|
||||
EscapeText: true,
|
||||
Newline: "<br/>",
|
||||
ConvertColorsToSpans: true, // Convert ANSI escape sequences to HTML <span> tags with CSS classes
|
||||
}
|
||||
t.RenderHTML()
|
||||
```
|
||||
to get:
|
||||
```html
|
||||
<table class="game-of-thrones">
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="right">#</th>
|
||||
<th>First Name</th>
|
||||
<th>Last Name</th>
|
||||
<th align="right">Salary</th>
|
||||
<th> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="right">1</td>
|
||||
<td>Arya</td>
|
||||
<td>Stark</td>
|
||||
<td align="right">3000</td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="right">20</td>
|
||||
<td>Jon</td>
|
||||
<td>Snow</td>
|
||||
<td align="right">2000</td>
|
||||
<td>You know nothing, Jon Snow!</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="right">300</td>
|
||||
<td>Tyrion</td>
|
||||
<td>Lannister</td>
|
||||
<td align="right">5000</td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td align="right"> </td>
|
||||
<td> </td>
|
||||
<td>Total</td>
|
||||
<td align="right">10000</td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary><strong>Markdown Table</strong></summary>
|
||||
|
||||
```golang
|
||||
t.RenderMarkdown()
|
||||
```
|
||||
to get:
|
||||
```markdown
|
||||
| # | First Name | Last Name | Salary | |
|
||||
| ---:| --- | --- | ---:| --- |
|
||||
| 1 | Arya | Stark | 3000 | |
|
||||
| 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! |
|
||||
| 300 | Tyrion | Lannister | 5000 | |
|
||||
| | | Total | 10000 | |
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
+101
-412
@@ -3,42 +3,7 @@
|
||||
|
||||
Pretty-print tables into ASCII/Unicode strings.
|
||||
|
||||
- Add Rows one-by-one or as a group (`AppendRow`/`AppendRows`)
|
||||
- Add Header(s) and Footer(s) (`AppendHeader`/`AppendFooter`)
|
||||
- Add a Separator manually after any Row (`AppendSeparator`)
|
||||
- Auto Index Rows (1, 2, 3 ...) and Columns (A, B, C, ...) (`SetAutoIndex`)
|
||||
- Auto Merge (_not supported in CSV/Markdown/TSV modes_)
|
||||
- Cells in a Row (`RowConfig.AutoMerge`)
|
||||
- Columns (`ColumnConfig.AutoMerge`) (_not supported in HTML mode_)
|
||||
- Limit the length of
|
||||
- Rows (`SetAllowedRowLength`)
|
||||
- Columns (`ColumnConfig.Width*`)
|
||||
- Auto-size Rows (`Style().Size.WidthMin` and `Style().Size.WidthMax`)
|
||||
- Page results by a specified number of Lines (`SetPageSize`)
|
||||
- Alignment - Horizontal & Vertical
|
||||
- Auto (horizontal) Align (numeric columns aligned Right)
|
||||
- Custom (horizontal) Align per column (`ColumnConfig.Align*`)
|
||||
- Custom (vertical) VAlign per column with multi-line cell support (`ColumnConfig.VAlign*`)
|
||||
- Mirror output to an `io.Writer` (ex. `os.StdOut`) (`SetOutputMirror`)
|
||||
- Sort by one or more Columns (`SortBy`)
|
||||
- Suppress/hide columns with no content (`SuppressEmptyColumns`)
|
||||
- Suppress trailing spaces in the last column (`SupressTrailingSpaces`)
|
||||
- Customizable Cell rendering per Column (`ColumnConfig.Transformer*`)
|
||||
- Hide any columns that you don't want displayed (`ColumnConfig.Hidden`)
|
||||
- Reset Headers/Rows/Footers at will to reuse the same Table Writer (`Reset*`)
|
||||
- Completely customizable styles (`SetStyle`/`Style`)
|
||||
- Many ready-to-use styles: [style.go](style.go)
|
||||
- Colorize Headers/Body/Footers using [../text/color.go](../text/color.go)
|
||||
- Custom text-case for Headers/Body/Footers
|
||||
- Enable separators between each row
|
||||
- Render table without a Border
|
||||
- and a lot more...
|
||||
- Render as:
|
||||
- (ASCII/Unicode) Table
|
||||
- CSV
|
||||
- HTML Table (with custom CSS Class)
|
||||
- Markdown Table
|
||||
- TSV
|
||||
## Sample Table Rendering
|
||||
|
||||
```
|
||||
+---------------------------------------------------------------------+
|
||||
@@ -57,396 +22,120 @@ Pretty-print tables into ASCII/Unicode strings.
|
||||
A demonstration of all the capabilities can be found here:
|
||||
[../cmd/demo-table](../cmd/demo-table)
|
||||
|
||||
If you want very specific examples, read ahead.
|
||||
If you want very specific examples, look at the [EXAMPLES.md](EXAMPLES.md) file.
|
||||
|
||||
**Hint**: I've tried to ensure that almost all supported use-cases are covered
|
||||
by unit-tests and that they print the table rendered. Run
|
||||
`go test -v github.com/jedib0t/go-pretty/v6/table` to see the test outputs and
|
||||
help you figure out how to do something.
|
||||
## Features
|
||||
|
||||
# Examples
|
||||
### Core Table Building
|
||||
|
||||
All the examples below are going to start with the following block, although
|
||||
nothing except a single Row is mandatory for the `Render()` function to render
|
||||
something:
|
||||
```golang
|
||||
package main
|
||||
- Add Rows one-by-one or as a group (`AppendRow`/`AppendRows`)
|
||||
- Add Header(s) and Footer(s) (`AppendHeader`/`AppendFooter`)
|
||||
- Add a Separator manually after any Row (`AppendSeparator`)
|
||||
- Add Title above the table (`SetTitle`)
|
||||
- Add Caption below the table (`SetCaption`)
|
||||
- Import 1D or 2D arrays/grids as rows (`ImportGrid`)
|
||||
- Reset Headers/Rows/Footers at will to reuse the same Table Writer (`Reset*`)
|
||||
|
||||
import (
|
||||
"os"
|
||||
### Indexing & Navigation
|
||||
|
||||
"github.com/jedib0t/go-pretty/v6/table"
|
||||
)
|
||||
- Auto Index Rows (1, 2, 3 ...) and Columns (A, B, C, ...) (`SetAutoIndex`)
|
||||
- Set which column is the index column (`SetIndexColumn`)
|
||||
- Pager interface for navigating through paged output (`Pager()`)
|
||||
- `GoTo(pageNum)` - Jump to specific page
|
||||
- `Next()` - Move to next page
|
||||
- `Prev()` - Move to previous page
|
||||
- `Location()` - Get current page number
|
||||
- `Render()` - Render current page
|
||||
- `SetOutputMirror()` - Mirror output to io.Writer
|
||||
|
||||
func main() {
|
||||
t := table.NewWriter()
|
||||
t.SetOutputMirror(os.Stdout)
|
||||
t.AppendHeader(table.Row{"#", "First Name", "Last Name", "Salary"})
|
||||
t.AppendRows([]table.Row{
|
||||
{1, "Arya", "Stark", 3000},
|
||||
{20, "Jon", "Snow", 2000, "You know nothing, Jon Snow!"},
|
||||
})
|
||||
t.AppendSeparator()
|
||||
t.AppendRow([]interface{}{300, "Tyrion", "Lannister", 5000})
|
||||
t.AppendFooter(table.Row{"", "", "Total", 10000})
|
||||
t.Render()
|
||||
}
|
||||
```
|
||||
Running the above will result in:
|
||||
```
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| # | FIRST NAME | LAST NAME | SALARY | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| 1 | Arya | Stark | 3000 | |
|
||||
| 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| 300 | Tyrion | Lannister | 5000 | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| | | TOTAL | 10000 | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
```
|
||||
### Auto Merge
|
||||
|
||||
## Styles
|
||||
- Auto Merge cells (_not supported in CSV/Markdown/TSV modes_)
|
||||
- Cells in a Row (`RowConfig.AutoMerge`)
|
||||
- Columns (`ColumnConfig.AutoMerge`) (_not supported in HTML mode_)
|
||||
- Custom alignment for merged cells (`RowConfig.AutoMergeAlign`)
|
||||
|
||||
You can customize almost every single thing about the table above. The previous
|
||||
example just defaulted to `StyleDefault` during `Render()`. You can use a
|
||||
ready-to-use style (as in [style.go](style.go)) or customize it as you want.
|
||||
### Size & Width Control
|
||||
|
||||
### Ready-to-use Styles
|
||||
- Limit the length of Rows (`SetAllowedRowLength` or `Style().Size.WidthMax`)
|
||||
- Auto-size Rows (`Style().Size.WidthMin` and `Style().Size.WidthMax`)
|
||||
- Column width control (`ColumnConfig.WidthMin` and `ColumnConfig.WidthMax`)
|
||||
- Custom width enforcement functions (`ColumnConfig.WidthMaxEnforcer`)
|
||||
- Default: `text.WrapText`
|
||||
- Options: `text.WrapSoft`, `text.WrapHard`, `text.Trim`, or custom function
|
||||
|
||||
Table comes with a bunch of ready-to-use Styles that make the table look really
|
||||
good. Set or Change the style using:
|
||||
```golang
|
||||
t.SetStyle(table.StyleLight)
|
||||
t.Render()
|
||||
```
|
||||
to get:
|
||||
```
|
||||
┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐
|
||||
│ # │ FIRST NAME │ LAST NAME │ SALARY │ │
|
||||
├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
|
||||
│ 1 │ Arya │ Stark │ 3000 │ │
|
||||
│ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │
|
||||
├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
|
||||
│ 300 │ Tyrion │ Lannister │ 5000 │ │
|
||||
├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
|
||||
│ │ │ TOTAL │ 10000 │ │
|
||||
└─────┴────────────┴───────────┴────────┴─────────────────────────────┘
|
||||
```
|
||||
### Alignment
|
||||
|
||||
Or if you want to use a full-color mode, and don't care for boxes, use:
|
||||
```golang
|
||||
t.SetStyle(table.StyleColoredBright)
|
||||
t.Render()
|
||||
```
|
||||
to get:
|
||||
- **Horizontal Alignment**
|
||||
- Auto (numeric columns aligned Right, text aligned Left)
|
||||
- Custom per column (`ColumnConfig.Align`, `AlignHeader`, `AlignFooter`)
|
||||
- Options: Left, Center, Right, Justify, Auto
|
||||
- **Vertical Alignment**
|
||||
- Custom per column with multi-line cell support (`ColumnConfig.VAlign`, `VAlignHeader`, `VAlignFooter`)
|
||||
- Options: Top, Middle, Bottom
|
||||
|
||||
<img src="images/table-StyleColoredBright.png" width="640px" alt="Colored Table"/>
|
||||
### Sorting & Filtering
|
||||
|
||||
### Roll your own Style
|
||||
- **Sorting**
|
||||
- Sort by one or more Columns (`SortBy`)
|
||||
- Multiple column sorting support
|
||||
- Various sort modes: Alphabetical, Numeric, Alpha-numeric, Numeric-alpha
|
||||
- Case-insensitive sorting option (`IgnoreCase`)
|
||||
- Custom sorting functions (`CustomLess`) for advanced sorting logic
|
||||
- **Filtering**
|
||||
- Filter by one or more Columns (`FilterBy`)
|
||||
- Multiple filters with AND logic (all must match)
|
||||
- Various filter operators:
|
||||
- Equality: Equal, NotEqual
|
||||
- Numeric: GreaterThan, GreaterThanOrEqual, LessThan, LessThanOrEqual
|
||||
- String: Contains, NotContains, StartsWith, EndsWith
|
||||
- Regex: RegexMatch, RegexNotMatch
|
||||
- Case-insensitive filtering option (`IgnoreCase`)
|
||||
- Custom filter functions (`CustomFilter`) for advanced filtering logic
|
||||
- Filters are applied before sorting
|
||||
- Suppress/hide columns with no content (`SuppressEmptyColumns`)
|
||||
- Hide specific columns (`ColumnConfig.Hidden`)
|
||||
- Suppress trailing spaces in the last column (`SuppressTrailingSpaces`)
|
||||
|
||||
You can also roll your own style:
|
||||
```golang
|
||||
t.SetStyle(table.Style{
|
||||
Name: "myNewStyle",
|
||||
Box: table.BoxStyle{
|
||||
BottomLeft: "\\",
|
||||
BottomRight: "/",
|
||||
BottomSeparator: "v",
|
||||
Left: "[",
|
||||
LeftSeparator: "{",
|
||||
MiddleHorizontal: "-",
|
||||
MiddleSeparator: "+",
|
||||
MiddleVertical: "|",
|
||||
PaddingLeft: "<",
|
||||
PaddingRight: ">",
|
||||
Right: "]",
|
||||
RightSeparator: "}",
|
||||
TopLeft: "(",
|
||||
TopRight: ")",
|
||||
TopSeparator: "^",
|
||||
UnfinishedRow: " ~~~",
|
||||
},
|
||||
Color: table.ColorOptions{
|
||||
IndexColumn: text.Colors{text.BgCyan, text.FgBlack},
|
||||
Footer: text.Colors{text.BgCyan, text.FgBlack},
|
||||
Header: text.Colors{text.BgHiCyan, text.FgBlack},
|
||||
Row: text.Colors{text.BgHiWhite, text.FgBlack},
|
||||
RowAlternate: text.Colors{text.BgWhite, text.FgBlack},
|
||||
},
|
||||
Format: table.FormatOptions{
|
||||
Footer: text.FormatUpper,
|
||||
Header: text.FormatUpper,
|
||||
Row: text.FormatDefault,
|
||||
},
|
||||
Options: table.Options{
|
||||
DrawBorder: true,
|
||||
SeparateColumns: true,
|
||||
SeparateFooter: true,
|
||||
SeparateHeader: true,
|
||||
SeparateRows: false,
|
||||
},
|
||||
})
|
||||
```
|
||||
### Customization & Styling
|
||||
|
||||
Or you can use one of the ready-to-use Styles, and just make a few tweaks:
|
||||
```golang
|
||||
t.SetStyle(table.StyleLight)
|
||||
t.Style().Color.Header = text.Colors{text.BgHiCyan, text.FgBlack}
|
||||
t.Style().Color.IndexColumn = text.Colors{text.BgHiCyan, text.FgBlack}
|
||||
t.Style().Format.Footer = text.FormatLower
|
||||
t.Style().Options.DrawBorder = false
|
||||
```
|
||||
- **Row Coloring**
|
||||
- Custom row painter function (`SetRowPainter`)
|
||||
- Row painter with attributes (`RowPainterWithAttributes`)
|
||||
- Access to row number and sorted position
|
||||
- **Cell Transformation**
|
||||
- Customizable Cell rendering per Column (`ColumnConfig.Transformer`, `TransformerHeader`, `TransformerFooter`)
|
||||
- Use built-in transformers from `text` package (Number, JSON, Time, URL, etc.)
|
||||
- **Column Styling**
|
||||
- Per-column colors (`ColumnConfig.Colors`, `ColorsHeader`, `ColorsFooter`)
|
||||
- Per-column alignment (horizontal and vertical)
|
||||
- Per-column width constraints
|
||||
- **Completely customizable styles** (`SetStyle`/`Style`)
|
||||
- Many ready-to-use styles: [style.go](style.go)
|
||||
- `StyleDefault` - Classic ASCII borders
|
||||
- `StyleLight` - Light box-drawing characters
|
||||
- `StyleBold` - Bold box-drawing characters
|
||||
- `StyleDouble` - Double box-drawing characters
|
||||
- `StyleRounded` - Rounded box-drawing characters
|
||||
- `StyleColoredBright` - Bright colors, no borders
|
||||
- `StyleColoredDark` - Dark colors, no borders
|
||||
- Many more colored variants (Blue, Cyan, Green, Magenta, Red, Yellow)
|
||||
- Colorize Headers/Body/Footers using [../text/color.go](../text/color.go)
|
||||
- Custom text-case for Headers/Body/Footers
|
||||
- Enable/disable separators between rows
|
||||
- Render table with or without borders
|
||||
- Customize box-drawing characters
|
||||
- Horizontal separators per section (title, header, rows, footer) using `BoxStyleHorizontal`
|
||||
- Title and caption styling options
|
||||
- HTML rendering options (CSS class, escaping, newlines, color conversion)
|
||||
- Bidirectional text support (`Style().Format.Direction`)
|
||||
|
||||
## Auto-Merge
|
||||
### Output Formats
|
||||
|
||||
You can auto-merge cells horizontally and vertically, but you have request for
|
||||
it specifically for each row/column using `RowConfig` or `ColumnConfig`.
|
||||
|
||||
```golang
|
||||
rowConfigAutoMerge := table.RowConfig{AutoMerge: true}
|
||||
|
||||
t := table.NewWriter()
|
||||
t.AppendHeader(table.Row{"Node IP", "Pods", "Namespace", "Container", "RCE", "RCE"}, rowConfigAutoMerge)
|
||||
t.AppendHeader(table.Row{"Node IP", "Pods", "Namespace", "Container", "EXE", "RUN"})
|
||||
t.AppendRow(table.Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 1", "Y", "Y"}, rowConfigAutoMerge)
|
||||
t.AppendRow(table.Row{"1.1.1.1", "Pod 1A", "NS 1A", "C 2", "Y", "N"}, rowConfigAutoMerge)
|
||||
t.AppendRow(table.Row{"1.1.1.1", "Pod 1A", "NS 1B", "C 3", "N", "N"}, rowConfigAutoMerge)
|
||||
t.AppendRow(table.Row{"1.1.1.1", "Pod 1B", "NS 2", "C 4", "N", "N"}, rowConfigAutoMerge)
|
||||
t.AppendRow(table.Row{"1.1.1.1", "Pod 1B", "NS 2", "C 5", "Y", "N"}, rowConfigAutoMerge)
|
||||
t.AppendRow(table.Row{"2.2.2.2", "Pod 2", "NS 3", "C 6", "Y", "Y"}, rowConfigAutoMerge)
|
||||
t.AppendRow(table.Row{"2.2.2.2", "Pod 2", "NS 3", "C 7", "Y", "Y"}, rowConfigAutoMerge)
|
||||
t.AppendFooter(table.Row{"", "", "", 7, 5, 3})
|
||||
t.SetAutoIndex(true)
|
||||
t.SetColumnConfigs([]table.ColumnConfig{
|
||||
{Number: 1, AutoMerge: true},
|
||||
{Number: 2, AutoMerge: true},
|
||||
{Number: 3, AutoMerge: true},
|
||||
{Number: 4, AutoMerge: true},
|
||||
{Number: 5, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter},
|
||||
{Number: 6, Align: text.AlignCenter, AlignFooter: text.AlignCenter, AlignHeader: text.AlignCenter},
|
||||
})
|
||||
t.SetStyle(table.StyleLight)
|
||||
t.Style().Options.SeparateRows = true
|
||||
fmt.Println(t.Render())
|
||||
```
|
||||
to get:
|
||||
```
|
||||
┌───┬─────────┬────────┬───────────┬───────────┬───────────┐
|
||||
│ │ NODE IP │ PODS │ NAMESPACE │ CONTAINER │ RCE │
|
||||
│ │ │ │ │ ├─────┬─────┤
|
||||
│ │ │ │ │ │ EXE │ RUN │
|
||||
├───┼─────────┼────────┼───────────┼───────────┼─────┴─────┤
|
||||
│ 1 │ 1.1.1.1 │ Pod 1A │ NS 1A │ C 1 │ Y │
|
||||
├───┤ │ │ ├───────────┼─────┬─────┤
|
||||
│ 2 │ │ │ │ C 2 │ Y │ N │
|
||||
├───┤ │ ├───────────┼───────────┼─────┴─────┤
|
||||
│ 3 │ │ │ NS 1B │ C 3 │ N │
|
||||
├───┤ ├────────┼───────────┼───────────┼───────────┤
|
||||
│ 4 │ │ Pod 1B │ NS 2 │ C 4 │ N │
|
||||
├───┤ │ │ ├───────────┼─────┬─────┤
|
||||
│ 5 │ │ │ │ C 5 │ Y │ N │
|
||||
├───┼─────────┼────────┼───────────┼───────────┼─────┴─────┤
|
||||
│ 6 │ 2.2.2.2 │ Pod 2 │ NS 3 │ C 6 │ Y │
|
||||
├───┤ │ │ ├───────────┼───────────┤
|
||||
│ 7 │ │ │ │ C 7 │ Y │
|
||||
├───┼─────────┼────────┼───────────┼───────────┼─────┬─────┤
|
||||
│ │ │ │ │ 7 │ 5 │ 3 │
|
||||
└───┴─────────┴────────┴───────────┴───────────┴─────┴─────┘
|
||||
```
|
||||
|
||||
## Paging
|
||||
|
||||
You can limit then number of lines rendered in a single "Page". This logic
|
||||
can handle rows with multiple lines too. Here is a simple example:
|
||||
```golang
|
||||
t.SetPageSize(1)
|
||||
t.Render()
|
||||
```
|
||||
to get:
|
||||
```
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| # | FIRST NAME | LAST NAME | SALARY | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| 1 | Arya | Stark | 3000 | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| | | TOTAL | 10000 | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| # | FIRST NAME | LAST NAME | SALARY | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| | | TOTAL | 10000 | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| # | FIRST NAME | LAST NAME | SALARY | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| 300 | Tyrion | Lannister | 5000 | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
| | | TOTAL | 10000 | |
|
||||
+-----+------------+-----------+--------+-----------------------------+
|
||||
```
|
||||
|
||||
## Sorting
|
||||
|
||||
Sorting can be done on one or more columns. The following code will make the
|
||||
rows be sorted first by "First Name" and then by "Last Name" (in case of similar
|
||||
"First Name" entries).
|
||||
```golang
|
||||
t.SortBy([]table.SortBy{
|
||||
{Name: "First Name", Mode: table.Asc},
|
||||
{Name: "Last Name", Mode: table.Asc},
|
||||
})
|
||||
```
|
||||
|
||||
## Wrapping (or) Row/Column Width restrictions
|
||||
|
||||
You can restrict the maximum (text) width for a Row:
|
||||
```golang
|
||||
t.SetAllowedRowLength(50)
|
||||
t.Render()
|
||||
```
|
||||
to get:
|
||||
```
|
||||
+-----+------------+-----------+--------+------- ~
|
||||
| # | FIRST NAME | LAST NAME | SALARY | ~
|
||||
+-----+------------+-----------+--------+------- ~
|
||||
| 1 | Arya | Stark | 3000 | ~
|
||||
| 20 | Jon | Snow | 2000 | You kn ~
|
||||
+-----+------------+-----------+--------+------- ~
|
||||
| 300 | Tyrion | Lannister | 5000 | ~
|
||||
+-----+------------+-----------+--------+------- ~
|
||||
| | | TOTAL | 10000 | ~
|
||||
+-----+------------+-----------+--------+------- ~
|
||||
```
|
||||
|
||||
## Column Control - Alignment, Colors, Width and more
|
||||
|
||||
You can control a lot of things about individual cells/columns which overrides
|
||||
global properties/styles using the `SetColumnConfig()` interface:
|
||||
- Alignment (horizontal & vertical)
|
||||
- Colorization
|
||||
- Transform individual cells based on the content
|
||||
- Visibility
|
||||
- Width (minimum & maximum)
|
||||
|
||||
```golang
|
||||
nameTransformer := text.Transformer(func(val interface{}) string {
|
||||
return text.Bold.Sprint(val)
|
||||
})
|
||||
|
||||
t.SetColumnConfigs([]ColumnConfig{
|
||||
{
|
||||
Name: "First Name",
|
||||
Align: text.AlignLeft,
|
||||
AlignFooter: text.AlignLeft,
|
||||
AlignHeader: text.AlignLeft,
|
||||
Colors: text.Colors{text.BgBlack, text.FgRed},
|
||||
ColorsHeader: text.Colors{text.BgRed, text.FgBlack, text.Bold},
|
||||
ColorsFooter: text.Colors{text.BgRed, text.FgBlack},
|
||||
Hidden: false,
|
||||
Transformer: nameTransformer,
|
||||
TransformerFooter: nameTransformer,
|
||||
TransformerHeader: nameTransformer,
|
||||
VAlign: text.VAlignMiddle,
|
||||
VAlignFooter: text.VAlignTop,
|
||||
VAlignHeader: text.VAlignBottom,
|
||||
WidthMin: 6,
|
||||
WidthMax: 64,
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Render As ...
|
||||
|
||||
Tables can be rendered in other common formats such as:
|
||||
|
||||
### ... CSV
|
||||
|
||||
```golang
|
||||
t.RenderCSV()
|
||||
```
|
||||
to get:
|
||||
```
|
||||
,First Name,Last Name,Salary,
|
||||
1,Arya,Stark,3000,
|
||||
20,Jon,Snow,2000,"You know nothing\, Jon Snow!"
|
||||
300,Tyrion,Lannister,5000,
|
||||
,,Total,10000,
|
||||
```
|
||||
|
||||
### ... HTML Table
|
||||
|
||||
```golang
|
||||
t.Style().HTML = table.HTMLOptions{
|
||||
CSSClass: "game-of-thrones",
|
||||
EmptyColumn: " ",
|
||||
EscapeText: true,
|
||||
Newline: "<br/>",
|
||||
}
|
||||
t.RenderHTML()
|
||||
```
|
||||
to get:
|
||||
```html
|
||||
<table class="game-of-thrones">
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="right">#</th>
|
||||
<th>First Name</th>
|
||||
<th>Last Name</th>
|
||||
<th align="right">Salary</th>
|
||||
<th> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="right">1</td>
|
||||
<td>Arya</td>
|
||||
<td>Stark</td>
|
||||
<td align="right">3000</td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="right">20</td>
|
||||
<td>Jon</td>
|
||||
<td>Snow</td>
|
||||
<td align="right">2000</td>
|
||||
<td>You know nothing, Jon Snow!</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="right">300</td>
|
||||
<td>Tyrion</td>
|
||||
<td>Lannister</td>
|
||||
<td align="right">5000</td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td align="right"> </td>
|
||||
<td> </td>
|
||||
<td>Total</td>
|
||||
<td align="right">10000</td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
```
|
||||
|
||||
### ... Markdown Table
|
||||
|
||||
```golang
|
||||
t.RenderMarkdown()
|
||||
```
|
||||
to get:
|
||||
```markdown
|
||||
| # | First Name | Last Name | Salary | |
|
||||
| ---:| --- | --- | ---:| --- |
|
||||
| 1 | Arya | Stark | 3000 | |
|
||||
| 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! |
|
||||
| 300 | Tyrion | Lannister | 5000 | |
|
||||
| | | Total | 10000 | |
|
||||
```
|
||||
- **Render as:**
|
||||
- (ASCII/Unicode) Table - Human-readable pretty format
|
||||
- CSV - Comma-separated values
|
||||
- HTML Table - With custom CSS Class and options
|
||||
- Markdown Table - Markdown-compatible format
|
||||
- TSV - Tab-separated values
|
||||
- Mirror output to an `io.Writer` (ex. `os.StdOut`) (`SetOutputMirror`)
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
package table
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FilterBy defines what to filter (Column Name or Number), how to filter (Operator),
|
||||
// and the value to compare against.
|
||||
type FilterBy struct {
|
||||
// Name is the name of the Column as it appears in the first Header row.
|
||||
// If a Header is not provided, or the name is not found in the header, this
|
||||
// will not work.
|
||||
Name string
|
||||
// Number is the Column # from left. When specified, it overrides the Name
|
||||
// property. If you know the exact Column number, use this instead of Name.
|
||||
Number int
|
||||
|
||||
// Operator defines how to compare the column value against the Value.
|
||||
Operator FilterOperator
|
||||
|
||||
// Value is the value to compare against. The type should match the expected
|
||||
// comparison type (string for string operations, numeric for numeric operations).
|
||||
// For Contains, StartsWith, EndsWith, and RegexMatch, Value should be a string.
|
||||
// For numeric comparisons (Equal, NotEqual, GreaterThan, etc.), Value can be
|
||||
// a number (int, float64) or a string representation of a number.
|
||||
Value interface{}
|
||||
|
||||
// IgnoreCase makes string comparisons case-insensitive (only applies to
|
||||
// string-based operators).
|
||||
IgnoreCase bool
|
||||
|
||||
// CustomFilter is a function that can be used to filter rows in a custom
|
||||
// manner. Note that:
|
||||
// * This overrides and ignores the Operator, Value, and IgnoreCase settings
|
||||
// * This is called after the column contents are converted to string form
|
||||
// * This function is expected to return:
|
||||
// * true => include the row
|
||||
// * false => exclude the row
|
||||
//
|
||||
// Use this when the default filtering logic is not sufficient.
|
||||
CustomFilter func(cellValue string) bool
|
||||
}
|
||||
|
||||
// FilterOperator defines how to filter.
|
||||
type FilterOperator int
|
||||
|
||||
const (
|
||||
// Equal filters rows where the column value equals the Value.
|
||||
Equal FilterOperator = iota
|
||||
// NotEqual filters rows where the column value does not equal the Value.
|
||||
NotEqual
|
||||
// GreaterThan filters rows where the column value is greater than the Value.
|
||||
GreaterThan
|
||||
// GreaterThanOrEqual filters rows where the column value is greater than or equal to the Value.
|
||||
GreaterThanOrEqual
|
||||
// LessThan filters rows where the column value is less than the Value.
|
||||
LessThan
|
||||
// LessThanOrEqual filters rows where the column value is less than or equal to the Value.
|
||||
LessThanOrEqual
|
||||
// Contains filters rows where the column value contains the Value (string search).
|
||||
Contains
|
||||
// NotContains filters rows where the column value does not contain the Value (string search).
|
||||
NotContains
|
||||
// StartsWith filters rows where the column value starts with the Value.
|
||||
StartsWith
|
||||
// EndsWith filters rows where the column value ends with the Value.
|
||||
EndsWith
|
||||
// RegexMatch filters rows where the column value matches the Value as a regular expression.
|
||||
RegexMatch
|
||||
// RegexNotMatch filters rows where the column value does not match the Value as a regular expression.
|
||||
RegexNotMatch
|
||||
)
|
||||
|
||||
func (t *Table) parseFilterBy(filterBy []FilterBy) []FilterBy {
|
||||
var resFilterBy []FilterBy
|
||||
for _, filter := range filterBy {
|
||||
colNum := 0
|
||||
if filter.Number > 0 && filter.Number <= t.numColumns {
|
||||
colNum = filter.Number
|
||||
} else if filter.Name != "" && len(t.rowsHeaderRaw) > 0 {
|
||||
// Parse from raw header rows
|
||||
for idx, colName := range t.rowsHeaderRaw[0] {
|
||||
if fmt.Sprint(colName) == filter.Name {
|
||||
colNum = idx + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if colNum > 0 {
|
||||
resFilterBy = append(resFilterBy, FilterBy{
|
||||
Name: filter.Name,
|
||||
Number: colNum,
|
||||
Operator: filter.Operator,
|
||||
Value: filter.Value,
|
||||
IgnoreCase: filter.IgnoreCase,
|
||||
CustomFilter: filter.CustomFilter,
|
||||
})
|
||||
}
|
||||
}
|
||||
return resFilterBy
|
||||
}
|
||||
|
||||
func (t *Table) matchesFiltersRaw(row Row, filters []FilterBy) bool {
|
||||
// All filters must match (AND logic)
|
||||
for _, filter := range filters {
|
||||
if !t.matchesFilterRaw(row, filter) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (t *Table) matchesFilterRaw(row Row, filter FilterBy) bool {
|
||||
colIdx := filter.Number - 1
|
||||
if colIdx < 0 || colIdx >= len(row) {
|
||||
return false
|
||||
}
|
||||
|
||||
cellValue := row[colIdx]
|
||||
cellValueStr := fmt.Sprint(cellValue)
|
||||
|
||||
// Use custom filter if provided
|
||||
if filter.CustomFilter != nil {
|
||||
return filter.CustomFilter(cellValueStr)
|
||||
}
|
||||
|
||||
// Use operator-based filtering
|
||||
return t.matchesOperator(cellValueStr, filter)
|
||||
}
|
||||
|
||||
func (t *Table) matchesOperator(cellValue string, filter FilterBy) bool {
|
||||
switch filter.Operator {
|
||||
case Equal:
|
||||
return t.compareEqual(cellValue, filter.Value, filter.IgnoreCase)
|
||||
case NotEqual:
|
||||
return !t.compareEqual(cellValue, filter.Value, filter.IgnoreCase)
|
||||
case GreaterThan:
|
||||
return t.compareNumeric(cellValue, filter.Value, func(a, b float64) bool { return a > b })
|
||||
case GreaterThanOrEqual:
|
||||
return t.compareNumeric(cellValue, filter.Value, func(a, b float64) bool { return a >= b })
|
||||
case LessThan:
|
||||
return t.compareNumeric(cellValue, filter.Value, func(a, b float64) bool { return a < b })
|
||||
case LessThanOrEqual:
|
||||
return t.compareNumeric(cellValue, filter.Value, func(a, b float64) bool { return a <= b })
|
||||
case Contains:
|
||||
return t.compareContains(cellValue, filter.Value, filter.IgnoreCase)
|
||||
case NotContains:
|
||||
return !t.compareContains(cellValue, filter.Value, filter.IgnoreCase)
|
||||
case StartsWith:
|
||||
return t.compareStartsWith(cellValue, filter.Value, filter.IgnoreCase)
|
||||
case EndsWith:
|
||||
return t.compareEndsWith(cellValue, filter.Value, filter.IgnoreCase)
|
||||
case RegexMatch:
|
||||
return t.compareRegexMatch(cellValue, filter.Value, filter.IgnoreCase)
|
||||
case RegexNotMatch:
|
||||
return !t.compareRegexMatch(cellValue, filter.Value, filter.IgnoreCase)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Table) compareEqual(cellValue string, filterValue interface{}, ignoreCase bool) bool {
|
||||
filterStr := fmt.Sprint(filterValue)
|
||||
if ignoreCase {
|
||||
return strings.EqualFold(cellValue, filterStr)
|
||||
}
|
||||
return cellValue == filterStr
|
||||
}
|
||||
|
||||
func (t *Table) compareNumeric(cellValue string, filterValue interface{}, compareFunc func(float64, float64) bool) bool {
|
||||
cellNum, cellErr := strconv.ParseFloat(cellValue, 64)
|
||||
if cellErr != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var filterNum float64
|
||||
switch v := filterValue.(type) {
|
||||
case int:
|
||||
filterNum = float64(v)
|
||||
case int64:
|
||||
filterNum = float64(v)
|
||||
case float64:
|
||||
filterNum = v
|
||||
case float32:
|
||||
filterNum = float64(v)
|
||||
case string:
|
||||
var err error
|
||||
filterNum, err = strconv.ParseFloat(v, 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
// Try to convert to string and parse
|
||||
filterStr := fmt.Sprint(filterValue)
|
||||
var err error
|
||||
filterNum, err = strconv.ParseFloat(filterStr, 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return compareFunc(cellNum, filterNum)
|
||||
}
|
||||
|
||||
func (t *Table) compareContains(cellValue string, filterValue interface{}, ignoreCase bool) bool {
|
||||
filterStr := fmt.Sprint(filterValue)
|
||||
if ignoreCase {
|
||||
return strings.Contains(strings.ToLower(cellValue), strings.ToLower(filterStr))
|
||||
}
|
||||
return strings.Contains(cellValue, filterStr)
|
||||
}
|
||||
|
||||
func (t *Table) compareStartsWith(cellValue string, filterValue interface{}, ignoreCase bool) bool {
|
||||
filterStr := fmt.Sprint(filterValue)
|
||||
if ignoreCase {
|
||||
return strings.HasPrefix(strings.ToLower(cellValue), strings.ToLower(filterStr))
|
||||
}
|
||||
return strings.HasPrefix(cellValue, filterStr)
|
||||
}
|
||||
|
||||
func (t *Table) compareEndsWith(cellValue string, filterValue interface{}, ignoreCase bool) bool {
|
||||
filterStr := fmt.Sprint(filterValue)
|
||||
if ignoreCase {
|
||||
return strings.HasSuffix(strings.ToLower(cellValue), strings.ToLower(filterStr))
|
||||
}
|
||||
return strings.HasSuffix(cellValue, filterStr)
|
||||
}
|
||||
|
||||
func (t *Table) compareRegexMatch(cellValue string, filterValue interface{}, ignoreCase bool) bool {
|
||||
filterStr := fmt.Sprint(filterValue)
|
||||
|
||||
// Compile the regex pattern
|
||||
var pattern *regexp.Regexp
|
||||
var err error
|
||||
if ignoreCase {
|
||||
pattern, err = regexp.Compile("(?i)" + filterStr)
|
||||
} else {
|
||||
pattern, err = regexp.Compile(filterStr)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
// If regex compilation fails, fall back to simple string matching
|
||||
return t.compareEqual(cellValue, filterValue, ignoreCase)
|
||||
}
|
||||
|
||||
return pattern.MatchString(cellValue)
|
||||
}
|
||||
+64
-23
@@ -20,7 +20,7 @@ import (
|
||||
// │ │ │ TOTAL │ 10000 │ │
|
||||
// └─────┴────────────┴───────────┴────────┴─────────────────────────────┘
|
||||
func (t *Table) Render() string {
|
||||
t.initForRender()
|
||||
t.initForRender(renderModeDefault)
|
||||
|
||||
var out strings.Builder
|
||||
if t.numColumns > 0 {
|
||||
@@ -50,6 +50,7 @@ func (t *Table) Render() string {
|
||||
return t.render(&out)
|
||||
}
|
||||
|
||||
//gocyclo:ignore
|
||||
func (t *Table) renderColumn(out *strings.Builder, row rowStr, colIdx int, maxColumnLength int, hint renderHint) int {
|
||||
numColumnsRendered := 1
|
||||
|
||||
@@ -93,11 +94,10 @@ func (t *Table) renderColumn(out *strings.Builder, row rowStr, colIdx int, maxCo
|
||||
numColumnsRendered++
|
||||
}
|
||||
}
|
||||
colStr = align.Apply(colStr, maxColumnLength)
|
||||
|
||||
// pad both sides of the column
|
||||
if !hint.isSeparatorRow || (hint.isSeparatorRow && mergeVertically) {
|
||||
colStr = t.style.Box.PaddingLeft + colStr + t.style.Box.PaddingRight
|
||||
colStr = t.style.Box.PaddingLeft + align.Apply(colStr, maxColumnLength) + t.style.Box.PaddingRight
|
||||
}
|
||||
|
||||
t.renderColumnColorized(out, colIdx, colStr, hint)
|
||||
@@ -112,9 +112,9 @@ func (t *Table) renderColumnAutoIndex(out *strings.Builder, hint renderHint) {
|
||||
if hint.isSeparatorRow {
|
||||
numChars := t.autoIndexVIndexMaxLength + utf8.RuneCountInString(t.style.Box.PaddingLeft) +
|
||||
utf8.RuneCountInString(t.style.Box.PaddingRight)
|
||||
chars := t.style.Box.MiddleHorizontal
|
||||
chars := t.style.Box.middleHorizontal(hint.separatorType)
|
||||
if hint.isAutoIndexColumn && hint.isHeaderOrFooterSeparator() {
|
||||
chars = text.RepeatAndTrim(" ", len(t.style.Box.MiddleHorizontal))
|
||||
chars = text.RepeatAndTrim(" ", len(chars))
|
||||
}
|
||||
outAutoIndex.WriteString(text.RepeatAndTrim(chars, numChars))
|
||||
} else {
|
||||
@@ -239,7 +239,7 @@ func (t *Table) renderLineMergeOutputs(out *strings.Builder, outLine *strings.Bu
|
||||
}
|
||||
|
||||
func (t *Table) renderMarginLeft(out *strings.Builder, hint renderHint) {
|
||||
out.WriteString(t.style.Format.Direction.Modifier())
|
||||
out.WriteString(t.directionModifier)
|
||||
if t.style.Options.DrawBorder {
|
||||
border := t.getBorderLeft(hint)
|
||||
colors := t.getBorderColors(hint)
|
||||
@@ -304,8 +304,10 @@ func (t *Table) renderRowSeparator(out *strings.Builder, hint renderHint) {
|
||||
} else if hint.isFooterRow && !t.style.Options.SeparateFooter {
|
||||
return
|
||||
}
|
||||
|
||||
hint.isSeparatorRow = true
|
||||
t.renderLine(out, t.rowSeparator, hint)
|
||||
separator := t.rowSeparatorStrings[hint.separatorType]
|
||||
t.renderLine(out, t.rowSeparators[separator], hint)
|
||||
}
|
||||
|
||||
func (t *Table) renderRows(out *strings.Builder, rows []rowStr, hint renderHint) {
|
||||
@@ -316,8 +318,17 @@ func (t *Table) renderRows(out *strings.Builder, rows []rowStr, hint renderHint)
|
||||
t.renderRow(out, row, hint)
|
||||
|
||||
if t.shouldSeparateRows(rowIdx, len(rows)) {
|
||||
hint.isFirstRow = false
|
||||
t.renderRowSeparator(out, hint)
|
||||
hintSep := hint
|
||||
hintSep.isFirstRow = false
|
||||
hintSep.isSeparatorRow = true
|
||||
if hintSep.isHeaderRow {
|
||||
hintSep.separatorType = separatorTypeHeaderMiddle
|
||||
} else if hintSep.isFooterRow {
|
||||
hintSep.separatorType = separatorTypeFooterMiddle
|
||||
} else {
|
||||
hintSep.separatorType = separatorTypeRowMiddle
|
||||
}
|
||||
t.renderRowSeparator(out, hintSep)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -328,46 +339,69 @@ func (t *Table) renderRowsBorderBottom(out *strings.Builder) {
|
||||
isBorderBottom: true,
|
||||
isFooterRow: true,
|
||||
rowNumber: len(t.rowsFooter),
|
||||
separatorType: separatorTypeFooterBottom,
|
||||
})
|
||||
} else {
|
||||
t.renderRowSeparator(out, renderHint{
|
||||
isBorderBottom: true,
|
||||
isFooterRow: false,
|
||||
rowNumber: len(t.rows),
|
||||
separatorType: separatorTypeRowBottom,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Table) renderRowsBorderTop(out *strings.Builder) {
|
||||
st := separatorTypeHeaderTop
|
||||
if t.title != "" {
|
||||
st = separatorTypeTitleBottom
|
||||
} else if len(t.rowsHeader) == 0 && !t.autoIndex {
|
||||
st = separatorTypeRowTop
|
||||
}
|
||||
|
||||
if len(t.rowsHeader) > 0 || t.autoIndex {
|
||||
t.renderRowSeparator(out, renderHint{
|
||||
isBorderTop: true,
|
||||
isHeaderRow: true,
|
||||
rowNumber: 0,
|
||||
isBorderTop: true,
|
||||
isHeaderRow: true,
|
||||
isSeparatorRow: true,
|
||||
rowNumber: 0,
|
||||
separatorType: st,
|
||||
})
|
||||
} else {
|
||||
t.renderRowSeparator(out, renderHint{
|
||||
isBorderTop: true,
|
||||
isHeaderRow: false,
|
||||
rowNumber: 0,
|
||||
isBorderTop: true,
|
||||
isHeaderRow: false,
|
||||
isSeparatorRow: true,
|
||||
rowNumber: 0,
|
||||
separatorType: st,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Table) renderRowsFooter(out *strings.Builder) {
|
||||
if len(t.rowsFooter) > 0 {
|
||||
t.renderRowSeparator(out, renderHint{
|
||||
isFooterRow: true,
|
||||
isFirstRow: true,
|
||||
isSeparatorRow: true,
|
||||
})
|
||||
// Only add separator before footer if there are data rows.
|
||||
// Otherwise, renderRowsHeader already added one.
|
||||
if len(t.rows) > 0 {
|
||||
t.renderRowSeparator(out, renderHint{
|
||||
isFooterRow: true,
|
||||
isFirstRow: true,
|
||||
isSeparatorRow: true,
|
||||
separatorType: separatorTypeFooterTop,
|
||||
})
|
||||
}
|
||||
t.renderRows(out, t.rowsFooter, renderHint{isFooterRow: true})
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Table) renderRowsHeader(out *strings.Builder) {
|
||||
if len(t.rowsHeader) > 0 || t.autoIndex {
|
||||
hintSeparator := renderHint{isHeaderRow: true, isLastRow: true, isSeparatorRow: true}
|
||||
hintSeparator := renderHint{
|
||||
isHeaderRow: true,
|
||||
isLastRow: true,
|
||||
isSeparatorRow: true,
|
||||
separatorType: separatorTypeHeaderMiddle,
|
||||
}
|
||||
|
||||
if len(t.rowsHeader) > 0 {
|
||||
t.renderRows(out, t.rowsHeader, renderHint{isHeaderRow: true})
|
||||
@@ -376,7 +410,13 @@ func (t *Table) renderRowsHeader(out *strings.Builder) {
|
||||
t.renderRow(out, t.getAutoIndexColumnIDs(), renderHint{isAutoIndexRow: true, isHeaderRow: true})
|
||||
hintSeparator.rowNumber = 1
|
||||
}
|
||||
t.renderRowSeparator(out, hintSeparator)
|
||||
|
||||
// Only add separator after header if there are data rows or footer rows.
|
||||
// Otherwise, the bottom border is rendered directly.
|
||||
if len(t.rows) > 0 || len(t.rowsFooter) > 0 || !t.style.Options.DoNotRenderSeparatorWhenEmpty {
|
||||
hintSeparator.separatorType = separatorTypeHeaderBottom
|
||||
t.renderRowSeparator(out, hintSeparator)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,8 +433,9 @@ func (t *Table) renderTitle(out *strings.Builder) {
|
||||
}
|
||||
if t.style.Options.DrawBorder {
|
||||
lenBorder := rowLength - text.StringWidthWithoutEscSequences(t.style.Box.TopLeft+t.style.Box.TopRight)
|
||||
middleHorizontal := t.style.Box.middleHorizontal(separatorTypeTitleTop)
|
||||
out.WriteString(colorsBorder.Sprint(t.style.Box.TopLeft))
|
||||
out.WriteString(colorsBorder.Sprint(text.RepeatAndTrim(t.style.Box.MiddleHorizontal, lenBorder)))
|
||||
out.WriteString(colorsBorder.Sprint(text.RepeatAndTrim(middleHorizontal, lenBorder)))
|
||||
out.WriteString(colorsBorder.Sprint(t.style.Box.TopRight))
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -14,7 +14,7 @@ import (
|
||||
// 300,Tyrion,Lannister,5000,
|
||||
// ,,Total,10000,
|
||||
func (t *Table) RenderCSV() string {
|
||||
t.initForRender()
|
||||
t.initForRender(renderModeCSV)
|
||||
|
||||
var out strings.Builder
|
||||
if t.numColumns > 0 {
|
||||
@@ -36,11 +36,11 @@ func (t *Table) RenderCSV() string {
|
||||
}
|
||||
|
||||
func (t *Table) csvFixCommas(str string) string {
|
||||
return strings.Replace(str, ",", "\\,", -1)
|
||||
return strings.ReplaceAll(str, ",", "\\,")
|
||||
}
|
||||
|
||||
func (t *Table) csvFixDoubleQuotes(str string) string {
|
||||
return strings.Replace(str, "\"", "\\\"", -1)
|
||||
return strings.ReplaceAll(str, "\"", "\\\"")
|
||||
}
|
||||
|
||||
func (t *Table) csvRenderRow(out *strings.Builder, row rowStr, hint renderHint) {
|
||||
@@ -54,7 +54,7 @@ func (t *Table) csvRenderRow(out *strings.Builder, row rowStr, hint renderHint)
|
||||
// auto-index column
|
||||
if colIdx == 0 && t.autoIndex {
|
||||
if hint.isRegularRow() {
|
||||
out.WriteString(fmt.Sprint(hint.rowNumber))
|
||||
fmt.Fprint(out, hint.rowNumber)
|
||||
}
|
||||
out.WriteRune(',')
|
||||
}
|
||||
|
||||
+11
@@ -15,6 +15,7 @@ type renderHint struct {
|
||||
isTitleRow bool // title row?
|
||||
rowLineNumber int // the line number for a multi-line row
|
||||
rowNumber int // the row number/index
|
||||
separatorType separatorType
|
||||
}
|
||||
|
||||
func (h *renderHint) isBorderOrSeparator() bool {
|
||||
@@ -37,3 +38,13 @@ func (h *renderHint) isHeaderOrFooterSeparator() bool {
|
||||
func (h *renderHint) isLastLineOfLastRow() bool {
|
||||
return h.isLastLineOfRow && h.isLastRow
|
||||
}
|
||||
|
||||
type renderMode string
|
||||
|
||||
const (
|
||||
renderModeDefault renderMode = "default"
|
||||
renderModeCSV renderMode = "csv"
|
||||
renderModeMarkdown renderMode = "markdown"
|
||||
renderModeTSV renderMode = "tsv"
|
||||
renderModeHTML renderMode = "html"
|
||||
)
|
||||
+10
-6
@@ -60,7 +60,7 @@ const (
|
||||
// </tfoot>
|
||||
// </table>
|
||||
func (t *Table) RenderHTML() string {
|
||||
t.initForRender()
|
||||
t.initForRender(renderModeHTML)
|
||||
|
||||
var out strings.Builder
|
||||
if t.numColumns > 0 {
|
||||
@@ -106,11 +106,15 @@ func (t *Table) htmlRenderCaption(out *strings.Builder) {
|
||||
}
|
||||
|
||||
func (t *Table) htmlRenderColumn(out *strings.Builder, colStr string) {
|
||||
if t.style.HTML.EscapeText {
|
||||
// convertEscSequencesToSpans already escapes text content, so skip
|
||||
// EscapeText if ConvertColorsToSpans is true
|
||||
if t.style.HTML.ConvertColorsToSpans {
|
||||
colStr = convertEscSequencesToSpans(colStr)
|
||||
} else if t.style.HTML.EscapeText {
|
||||
colStr = html.EscapeString(colStr)
|
||||
}
|
||||
if t.style.HTML.Newline != "\n" {
|
||||
colStr = strings.Replace(colStr, "\n", t.style.HTML.Newline, -1)
|
||||
colStr = strings.ReplaceAll(colStr, "\n", t.style.HTML.Newline)
|
||||
}
|
||||
out.WriteString(colStr)
|
||||
}
|
||||
@@ -147,7 +151,7 @@ func (t *Table) htmlRenderColumnAutoIndex(out *strings.Builder, hint renderHint)
|
||||
out.WriteString("</td>\n")
|
||||
} else {
|
||||
out.WriteString(" <td align=\"right\">")
|
||||
out.WriteString(fmt.Sprint(hint.rowNumber))
|
||||
fmt.Fprint(out, hint.rowNumber)
|
||||
out.WriteString("</td>\n")
|
||||
}
|
||||
}
|
||||
@@ -187,10 +191,10 @@ func (t *Table) htmlRenderRow(out *strings.Builder, row rowStr, hint renderHint)
|
||||
t.htmlRenderColumnAttributes(out, colIdx, hint, align)
|
||||
if extraColumnsRendered > 0 {
|
||||
out.WriteString(" colspan=")
|
||||
out.WriteString(fmt.Sprint(extraColumnsRendered + 1))
|
||||
fmt.Fprint(out, extraColumnsRendered+1)
|
||||
} else if rowSpan := t.shouldMergeCellsVerticallyBelow(colIdx, hint); rowSpan > 1 {
|
||||
out.WriteString(" rowspan=")
|
||||
out.WriteString(fmt.Sprint(rowSpan))
|
||||
fmt.Fprint(out, rowSpan)
|
||||
}
|
||||
out.WriteString(">")
|
||||
if len(colStr) == 0 {
|
||||
|
||||
+154
-30
@@ -43,11 +43,15 @@ func (t *Table) analyzeAndStringifyColumn(colIdx int, col interface{}, hint rend
|
||||
} else if colStrVal, ok := col.(string); ok {
|
||||
colStr = colStrVal
|
||||
} else {
|
||||
colStr = fmt.Sprint(col)
|
||||
colStr = convertValueToString(col)
|
||||
}
|
||||
colStr = strings.ReplaceAll(colStr, "\t", " ")
|
||||
colStr = text.ProcessCRLF(colStr)
|
||||
return fmt.Sprintf("%s%s", t.style.Format.Direction.Modifier(), colStr)
|
||||
// Avoid fmt.Sprintf when direction modifier is empty (most common case)
|
||||
if t.directionModifier == "" {
|
||||
return colStr
|
||||
}
|
||||
return t.directionModifier + colStr
|
||||
}
|
||||
|
||||
func (t *Table) extractMaxColumnLengths(rows []rowStr, hint renderHint) {
|
||||
@@ -149,13 +153,18 @@ func (t *Table) reBalanceMaxMergedColumnLengths() {
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Table) initForRender() {
|
||||
func (t *Table) initForRender(mode renderMode) {
|
||||
t.renderMode = mode
|
||||
|
||||
// pick a default style if none was set until now
|
||||
t.Style()
|
||||
|
||||
// reset rendering state
|
||||
t.reset()
|
||||
|
||||
// cache the direction modifier to avoid repeated calls
|
||||
t.directionModifier = t.style.Format.Direction.Modifier()
|
||||
|
||||
// initialize the column configs and normalize them
|
||||
t.initForRenderColumnConfigs()
|
||||
|
||||
@@ -280,11 +289,15 @@ func (t *Table) initForRenderPaddedColumns() {
|
||||
}
|
||||
|
||||
func (t *Table) initForRenderRows() {
|
||||
// auto-index: calc the index column's max length
|
||||
t.autoIndexVIndexMaxLength = len(fmt.Sprint(len(t.rowsRaw)))
|
||||
// filter the rows as requested (before stringification and sorting)
|
||||
t.initForRenderFilterRows()
|
||||
|
||||
// stringify all the rows to make it easy to render
|
||||
t.rows = t.initForRenderRowsStringify(t.rowsRaw, renderHint{})
|
||||
// auto-index: calc the index column's max length
|
||||
t.autoIndexVIndexMaxLength = len(fmt.Sprint(len(t.rowsRawFiltered)))
|
||||
|
||||
// stringify the filtered rows
|
||||
t.numColumns = 0
|
||||
t.rows = t.initForRenderRowsStringify(t.rowsRawFiltered, renderHint{})
|
||||
t.rowsFooter = t.initForRenderRowsStringify(t.rowsFooterRaw, renderHint{isFooterRow: true})
|
||||
t.rowsHeader = t.initForRenderRowsStringify(t.rowsHeaderRaw, renderHint{isHeaderRow: true})
|
||||
|
||||
@@ -301,6 +314,60 @@ func (t *Table) initForRenderRows() {
|
||||
t.initForRenderHideColumns()
|
||||
}
|
||||
|
||||
// initForRenderFilterRows filters the raw rows by removing non-matching rows from t.rowsRawFiltered.
|
||||
func (t *Table) initForRenderFilterRows() {
|
||||
// Restore original rows before filtering (in case of multiple renders with different filters)
|
||||
if len(t.rowsRaw) > 0 {
|
||||
t.rowsRawFiltered = make([]Row, len(t.rowsRaw))
|
||||
for i, row := range t.rowsRaw {
|
||||
rowCopy := make(Row, len(row))
|
||||
copy(rowCopy, row)
|
||||
t.rowsRawFiltered[i] = rowCopy
|
||||
}
|
||||
}
|
||||
|
||||
if len(t.filterBy) == 0 {
|
||||
// No filters, nothing to do
|
||||
return
|
||||
}
|
||||
|
||||
// Store original separators and track which rows are kept
|
||||
originalSeparators := make(map[int]bool)
|
||||
for k, v := range t.separators {
|
||||
originalSeparators[k] = v
|
||||
}
|
||||
|
||||
// Calculate numColumns from raw rows/headers for filter parsing
|
||||
t.calculateNumColumnsFromRaw()
|
||||
parsedFilterBy := t.parseFilterBy(t.filterBy)
|
||||
if len(parsedFilterBy) == 0 {
|
||||
// No valid filters, nothing to do
|
||||
return
|
||||
}
|
||||
|
||||
// Filter rows in place and track which original rows were kept
|
||||
filteredRows := t.rowsRawFiltered[:0]
|
||||
keptIndices := make([]int, 0, len(t.rowsRawFiltered))
|
||||
for origIdx, row := range t.rowsRawFiltered {
|
||||
if t.matchesFiltersRaw(row, parsedFilterBy) {
|
||||
filteredRows = append(filteredRows, row)
|
||||
keptIndices = append(keptIndices, origIdx)
|
||||
}
|
||||
}
|
||||
t.rowsRawFiltered = filteredRows
|
||||
|
||||
// Update separators map to reflect filtered rows
|
||||
if len(originalSeparators) > 0 {
|
||||
newSeparators := make(map[int]bool)
|
||||
for newIdx, origIdx := range keptIndices {
|
||||
if originalSeparators[origIdx] {
|
||||
newSeparators[newIdx] = true
|
||||
}
|
||||
}
|
||||
t.separators = newSeparators
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Table) initForRenderRowsStringify(rows []Row, hint renderHint) []rowStr {
|
||||
rowsStr := make([]rowStr, len(rows))
|
||||
for idx, row := range rows {
|
||||
@@ -315,36 +382,92 @@ func (t *Table) initForRenderRowPainterColors() {
|
||||
return
|
||||
}
|
||||
|
||||
// generate the colors
|
||||
t.rowsColors = make([]text.Colors, len(t.rowsRaw))
|
||||
for idx, row := range t.rowsRaw {
|
||||
idxColors := idx
|
||||
// generate the colors for the final rows (after filtering and sorting)
|
||||
// rowsColors will be indexed by the final position in t.rows
|
||||
t.rowsColors = make([]text.Colors, len(t.rows))
|
||||
|
||||
// For each final position, find the row index in t.rowsRawFiltered (which is already filtered)
|
||||
for finalPos := range t.rows {
|
||||
var rowIdx int
|
||||
|
||||
if len(t.sortedRowIndices) > 0 {
|
||||
// override with the sorted row index
|
||||
for j := 0; j < len(t.sortedRowIndices); j++ {
|
||||
if t.sortedRowIndices[j] == idx {
|
||||
idxColors = j
|
||||
break
|
||||
}
|
||||
}
|
||||
// Rows were sorted: finalPos -> sortedRowIndices[finalPos] -> rowIdx in t.rowsRawFiltered
|
||||
rowIdx = t.sortedRowIndices[finalPos]
|
||||
} else {
|
||||
// No sorting: finalPos -> rowIdx in t.rowsRawFiltered
|
||||
rowIdx = finalPos
|
||||
}
|
||||
|
||||
if t.rowPainter != nil {
|
||||
t.rowsColors[idxColors] = t.rowPainter(row)
|
||||
} else if t.rowPainterWithAttributes != nil {
|
||||
t.rowsColors[idxColors] = t.rowPainterWithAttributes(row, RowAttributes{
|
||||
Number: idx + 1,
|
||||
NumberSorted: idxColors + 1,
|
||||
})
|
||||
if rowIdx >= 0 && rowIdx < len(t.rowsRawFiltered) {
|
||||
row := t.rowsRawFiltered[rowIdx]
|
||||
if t.rowPainter != nil {
|
||||
t.rowsColors[finalPos] = t.rowPainter(row)
|
||||
} else if t.rowPainterWithAttributes != nil {
|
||||
t.rowsColors[finalPos] = t.rowPainterWithAttributes(row, RowAttributes{
|
||||
Number: rowIdx + 1,
|
||||
NumberSorted: finalPos + 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Table) initForRenderRowSeparator() {
|
||||
t.rowSeparator = make(rowStr, t.numColumns)
|
||||
for colIdx, maxColumnLength := range t.maxColumnLengths {
|
||||
maxColumnLength += text.StringWidthWithoutEscSequences(t.style.Box.PaddingLeft + t.style.Box.PaddingRight)
|
||||
t.rowSeparator[colIdx] = text.RepeatAndTrim(t.style.Box.MiddleHorizontal, maxColumnLength)
|
||||
// this is needed only for default render mode
|
||||
if t.renderMode != renderModeDefault {
|
||||
return
|
||||
}
|
||||
|
||||
// init the separatorType -> separator-string map
|
||||
t.initForRenderRowSeparatorStrings()
|
||||
|
||||
// init the separator-string -> separator-row map
|
||||
t.rowSeparators = make(map[string]rowStr, len(t.rowSeparatorStrings))
|
||||
paddingLength := text.StringWidthWithoutEscSequences(t.style.Box.PaddingLeft + t.style.Box.PaddingRight)
|
||||
for _, separator := range t.rowSeparatorStrings {
|
||||
t.rowSeparators[separator] = make(rowStr, t.numColumns)
|
||||
for colIdx, maxColumnLength := range t.maxColumnLengths {
|
||||
t.rowSeparators[separator][colIdx] = text.RepeatAndTrim(separator, maxColumnLength+paddingLength)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Table) initForRenderRowSeparatorStrings() {
|
||||
// allocate and init only the separators that are needed
|
||||
t.rowSeparatorStrings = make(map[separatorType]string)
|
||||
addSeparatorType := func(st separatorType) {
|
||||
t.rowSeparatorStrings[st] = t.style.Box.middleHorizontal(st)
|
||||
}
|
||||
|
||||
// for other render modes, we need all the separators
|
||||
if t.title != "" {
|
||||
addSeparatorType(separatorTypeTitleTop)
|
||||
addSeparatorType(separatorTypeTitleBottom)
|
||||
}
|
||||
if len(t.rowsHeader) > 0 || t.autoIndex {
|
||||
addSeparatorType(separatorTypeHeaderTop)
|
||||
addSeparatorType(separatorTypeHeaderBottom)
|
||||
if len(t.rowsHeader) > 1 {
|
||||
addSeparatorType(separatorTypeHeaderMiddle)
|
||||
}
|
||||
}
|
||||
if len(t.rows) > 0 {
|
||||
addSeparatorType(separatorTypeRowTop)
|
||||
addSeparatorType(separatorTypeRowBottom)
|
||||
if len(t.rows) > 1 {
|
||||
addSeparatorType(separatorTypeRowMiddle)
|
||||
}
|
||||
} else if len(t.rowsHeader) > 0 || t.autoIndex {
|
||||
// When there are headers but no data rows, we still need separatorTypeRowBottom
|
||||
// for the bottom border.
|
||||
addSeparatorType(separatorTypeRowBottom)
|
||||
}
|
||||
if len(t.rowsFooter) > 0 || t.autoIndex {
|
||||
addSeparatorType(separatorTypeFooterTop)
|
||||
addSeparatorType(separatorTypeFooterBottom)
|
||||
if len(t.rowsFooter) > 1 {
|
||||
addSeparatorType(separatorTypeFooterMiddle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,9 +525,10 @@ func (t *Table) reset() {
|
||||
t.maxRowLength = 0
|
||||
t.numColumns = 0
|
||||
t.numLinesRendered = 0
|
||||
t.rowSeparator = nil
|
||||
t.rowSeparators = nil
|
||||
t.rows = nil
|
||||
t.rowsColors = nil
|
||||
t.rowsFooter = nil
|
||||
t.rowsHeader = nil
|
||||
t.sortedRowIndices = nil
|
||||
}
|
||||
+26
-15
@@ -14,7 +14,7 @@ import (
|
||||
// | 300 | Tyrion | Lannister | 5000 | |
|
||||
// | | | Total | 10000 | |
|
||||
func (t *Table) RenderMarkdown() string {
|
||||
t.initForRender()
|
||||
t.initForRender(renderModeMarkdown)
|
||||
|
||||
var out strings.Builder
|
||||
if t.numColumns > 0 {
|
||||
@@ -47,19 +47,15 @@ func (t *Table) markdownRenderRow(out *strings.Builder, row rowStr, hint renderH
|
||||
for colIdx := 0; colIdx < t.numColumns; colIdx++ {
|
||||
t.markdownRenderRowAutoIndex(out, colIdx, hint)
|
||||
|
||||
if hint.isSeparatorRow {
|
||||
out.WriteString(t.getAlign(colIdx, hint).MarkdownProperty())
|
||||
} else {
|
||||
var colStr string
|
||||
if colIdx < len(row) {
|
||||
colStr = row[colIdx]
|
||||
}
|
||||
out.WriteRune(' ')
|
||||
colStr = strings.ReplaceAll(colStr, "|", "\\|")
|
||||
colStr = strings.ReplaceAll(colStr, "\n", "<br/>")
|
||||
out.WriteString(colStr)
|
||||
out.WriteRune(' ')
|
||||
var colStr string
|
||||
if colIdx < len(row) {
|
||||
colStr = row[colIdx]
|
||||
}
|
||||
out.WriteRune(' ')
|
||||
colStr = strings.ReplaceAll(colStr, "|", "\\|")
|
||||
colStr = strings.ReplaceAll(colStr, "\n", "<br/>")
|
||||
out.WriteString(colStr)
|
||||
out.WriteRune(' ')
|
||||
out.WriteRune('|')
|
||||
}
|
||||
}
|
||||
@@ -70,7 +66,7 @@ func (t *Table) markdownRenderRowAutoIndex(out *strings.Builder, colIdx int, hin
|
||||
if hint.isSeparatorRow {
|
||||
out.WriteString("---:")
|
||||
} else if hint.isRegularRow() {
|
||||
out.WriteString(fmt.Sprintf("%d ", hint.rowNumber))
|
||||
fmt.Fprintf(out, "%d ", hint.rowNumber)
|
||||
}
|
||||
out.WriteRune('|')
|
||||
}
|
||||
@@ -83,7 +79,7 @@ func (t *Table) markdownRenderRows(out *strings.Builder, rows []rowStr, hint ren
|
||||
t.markdownRenderRow(out, row, hint)
|
||||
|
||||
if idx == len(rows)-1 && hint.isHeaderRow {
|
||||
t.markdownRenderRow(out, t.rowSeparator, renderHint{isSeparatorRow: true})
|
||||
t.markdownRenderSeparator(out, renderHint{isSeparatorRow: true})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,6 +97,21 @@ func (t *Table) markdownRenderRowsHeader(out *strings.Builder) {
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Table) markdownRenderSeparator(out *strings.Builder, hint renderHint) {
|
||||
// when working on line number 2 or more, insert a newline first
|
||||
if out.Len() > 0 {
|
||||
out.WriteRune('\n')
|
||||
}
|
||||
|
||||
out.WriteRune('|')
|
||||
for colIdx := 0; colIdx < t.numColumns; colIdx++ {
|
||||
t.markdownRenderRowAutoIndex(out, colIdx, hint)
|
||||
|
||||
out.WriteString(t.getAlign(colIdx, hint).MarkdownProperty())
|
||||
out.WriteRune('|')
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Table) markdownRenderTitle(out *strings.Builder) {
|
||||
if t.title != "" {
|
||||
out.WriteString("# ")
|
||||
|
||||
+3
-3
@@ -6,7 +6,7 @@ import (
|
||||
)
|
||||
|
||||
func (t *Table) RenderTSV() string {
|
||||
t.initForRender()
|
||||
t.initForRender(renderModeTSV)
|
||||
|
||||
var out strings.Builder
|
||||
|
||||
@@ -40,7 +40,7 @@ func (t *Table) tsvRenderRow(out *strings.Builder, row rowStr, hint renderHint)
|
||||
for idx, col := range row {
|
||||
if idx == 0 && t.autoIndex {
|
||||
if hint.isRegularRow() {
|
||||
out.WriteString(fmt.Sprint(hint.rowNumber))
|
||||
fmt.Fprint(out, hint.rowNumber)
|
||||
}
|
||||
out.WriteRune('\t')
|
||||
}
|
||||
@@ -51,7 +51,7 @@ func (t *Table) tsvRenderRow(out *strings.Builder, row rowStr, hint renderHint)
|
||||
|
||||
if strings.ContainsAny(col, "\t\n\"") || strings.Contains(col, " ") {
|
||||
col = strings.ReplaceAll(col, "\"", "\"\"") // fix double-quotes
|
||||
out.WriteString(fmt.Sprintf("\"%s\"", col))
|
||||
fmt.Fprintf(out, "\"%s\"", col)
|
||||
} else {
|
||||
out.WriteString(col)
|
||||
}
|
||||
|
||||
+64
-64
@@ -21,6 +21,18 @@ type SortBy struct {
|
||||
|
||||
// IgnoreCase makes sorting case-insensitive
|
||||
IgnoreCase bool
|
||||
|
||||
// CustomLess is a function that can be used to sort the column in a custom
|
||||
// manner. Note that:
|
||||
// * This overrides and ignores the Mode and IgnoreCase settings
|
||||
// * This is called after the column contents are converted to string form
|
||||
// * This function is expected to return:
|
||||
// * -1 => when iStr comes before jStr
|
||||
// * 0 => when iStr and jStr are considered equal
|
||||
// * 1 => when iStr comes after jStr
|
||||
//
|
||||
// Use this when the default sorting logic is not sufficient.
|
||||
CustomLess func(iStr string, jStr string) int
|
||||
}
|
||||
|
||||
// SortMode defines How to sort.
|
||||
@@ -49,12 +61,6 @@ const (
|
||||
DscNumericAlpha
|
||||
)
|
||||
|
||||
type rowsSorter struct {
|
||||
rows []rowStr
|
||||
sortBy []SortBy
|
||||
sortedIndices []int
|
||||
}
|
||||
|
||||
// getSortedRowIndices sorts and returns the row indices in Sorted order as
|
||||
// directed by Table.sortBy which can be set using Table.SortBy(...)
|
||||
func (t *Table) getSortedRowIndices() []int {
|
||||
@@ -63,11 +69,31 @@ func (t *Table) getSortedRowIndices() []int {
|
||||
sortedIndices[idx] = idx
|
||||
}
|
||||
|
||||
if t.sortBy != nil && len(t.sortBy) > 0 {
|
||||
sort.Sort(rowsSorter{
|
||||
rows: t.rows,
|
||||
sortBy: t.parseSortBy(t.sortBy),
|
||||
sortedIndices: sortedIndices,
|
||||
if len(t.sortBy) > 0 {
|
||||
parsedSortBy := t.parseSortBy(t.sortBy)
|
||||
sort.Slice(sortedIndices, func(i, j int) bool {
|
||||
isEqual, isLess := false, false
|
||||
realI, realJ := sortedIndices[i], sortedIndices[j]
|
||||
for _, sortBy := range parsedSortBy {
|
||||
// extract the values/cells from the rows for comparison
|
||||
rowI, rowJ, colIdx := t.rows[realI], t.rows[realJ], sortBy.Number-1
|
||||
iVal, jVal := "", ""
|
||||
if colIdx < len(rowI) {
|
||||
iVal = rowI[colIdx]
|
||||
}
|
||||
if colIdx < len(rowJ) {
|
||||
jVal = rowJ[colIdx]
|
||||
}
|
||||
|
||||
// compare and choose whether to continue
|
||||
isEqual, isLess = less(iVal, jVal, sortBy)
|
||||
// if the values are not equal, return the result immediately
|
||||
if !isEqual {
|
||||
return isLess
|
||||
}
|
||||
// if the values are equal, continue to the next column
|
||||
}
|
||||
return isLess
|
||||
})
|
||||
}
|
||||
|
||||
@@ -94,48 +120,32 @@ func (t *Table) parseSortBy(sortBy []SortBy) []SortBy {
|
||||
Number: colNum,
|
||||
Mode: col.Mode,
|
||||
IgnoreCase: col.IgnoreCase,
|
||||
CustomLess: col.CustomLess,
|
||||
})
|
||||
}
|
||||
}
|
||||
return resSortBy
|
||||
}
|
||||
|
||||
func (rs rowsSorter) Len() int {
|
||||
return len(rs.rows)
|
||||
}
|
||||
|
||||
func (rs rowsSorter) Swap(i, j int) {
|
||||
rs.sortedIndices[i], rs.sortedIndices[j] = rs.sortedIndices[j], rs.sortedIndices[i]
|
||||
}
|
||||
|
||||
func (rs rowsSorter) Less(i, j int) bool {
|
||||
shouldContinue, returnValue := false, false
|
||||
realI, realJ := rs.sortedIndices[i], rs.sortedIndices[j]
|
||||
for _, sortBy := range rs.sortBy {
|
||||
// extract the values/cells from the rows for comparison
|
||||
rowI, rowJ, colIdx := rs.rows[realI], rs.rows[realJ], sortBy.Number-1
|
||||
iVal, jVal := "", ""
|
||||
if colIdx < len(rowI) {
|
||||
iVal = rowI[colIdx]
|
||||
}
|
||||
if colIdx < len(rowJ) {
|
||||
jVal = rowJ[colIdx]
|
||||
}
|
||||
|
||||
// compare and choose whether to continue
|
||||
shouldContinue, returnValue = less(iVal, jVal, sortBy)
|
||||
if !shouldContinue {
|
||||
break
|
||||
func less(iVal string, jVal string, sb SortBy) (bool, bool) {
|
||||
if sb.CustomLess != nil {
|
||||
// use the custom less function to compare the values
|
||||
rc := sb.CustomLess(iVal, jVal)
|
||||
if rc < 0 {
|
||||
return false, true
|
||||
} else if rc > 0 {
|
||||
return false, false
|
||||
} else { // rc == 0
|
||||
return true, false
|
||||
}
|
||||
}
|
||||
return returnValue
|
||||
}
|
||||
|
||||
func less(iVal string, jVal string, sb SortBy) (bool, bool) {
|
||||
// if the values are equal, return fast to continue to next column
|
||||
if iVal == jVal {
|
||||
return true, false
|
||||
}
|
||||
|
||||
// otherwise, use the default sorting logic defined by Mode and IgnoreCase
|
||||
switch sb.Mode {
|
||||
case Asc, Dsc:
|
||||
return lessAlphabetic(iVal, jVal, sb)
|
||||
@@ -168,37 +178,27 @@ func lessAlphabetic(iVal string, jVal string, sb SortBy) (bool, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func lessAlphaNumericI(sb SortBy) (bool, bool) {
|
||||
// i == "abc"; j == 5
|
||||
switch sb.Mode {
|
||||
case AscAlphaNumeric, DscAlphaNumeric:
|
||||
return false, true
|
||||
default: // AscNumericAlpha, DscNumericAlpha
|
||||
return false, false
|
||||
}
|
||||
}
|
||||
|
||||
func lessAlphaNumericJ(sb SortBy) (bool, bool) {
|
||||
// i == 5; j == "abc"
|
||||
switch sb.Mode {
|
||||
case AscAlphaNumeric, DscAlphaNumeric:
|
||||
return false, false
|
||||
default: // AscNumericAlpha, DscNumericAlpha:
|
||||
return false, true
|
||||
}
|
||||
}
|
||||
|
||||
func lessMixedMode(iVal string, jVal string, sb SortBy) (bool, bool) {
|
||||
iNumVal, iErr := strconv.ParseFloat(iVal, 64)
|
||||
jNumVal, jErr := strconv.ParseFloat(jVal, 64)
|
||||
if iErr != nil && jErr != nil { // both are alphanumeric
|
||||
return lessAlphabetic(iVal, jVal, sb)
|
||||
}
|
||||
if iErr != nil { // iVal is alphabetic, jVal is numeric
|
||||
return lessAlphaNumericI(sb)
|
||||
if iErr != nil { // iVal == "abc"; jVal == 5
|
||||
switch sb.Mode {
|
||||
case AscAlphaNumeric, DscAlphaNumeric:
|
||||
return false, true
|
||||
default: // AscNumericAlpha, DscNumericAlpha
|
||||
return false, false
|
||||
}
|
||||
}
|
||||
if jErr != nil { // iVal is numeric, jVal is alphabetic
|
||||
return lessAlphaNumericJ(sb)
|
||||
if jErr != nil { // iVal == 5; jVal == "abc"
|
||||
switch sb.Mode {
|
||||
case AscAlphaNumeric, DscAlphaNumeric:
|
||||
return false, false
|
||||
default: // AscNumericAlpha, DscNumericAlpha:
|
||||
return false, true
|
||||
}
|
||||
}
|
||||
// both values numeric
|
||||
return lessNumericVal(iNumVal, jNumVal, sb)
|
||||
|
||||
-592
@@ -1,9 +1,5 @@
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/jedib0t/go-pretty/v6/text"
|
||||
)
|
||||
|
||||
// Style declares how to render the Table and provides very fine-grained control
|
||||
// on how the Table gets rendered on the Console.
|
||||
type Style struct {
|
||||
@@ -340,591 +336,3 @@ var (
|
||||
Title: TitleOptionsDefault,
|
||||
}
|
||||
)
|
||||
|
||||
// BoxStyle defines the characters/strings to use to render the borders and
|
||||
// separators for the Table.
|
||||
type BoxStyle struct {
|
||||
BottomLeft string
|
||||
BottomRight string
|
||||
BottomSeparator string
|
||||
EmptySeparator string
|
||||
Left string
|
||||
LeftSeparator string
|
||||
MiddleHorizontal string
|
||||
MiddleSeparator string
|
||||
MiddleVertical string
|
||||
PaddingLeft string
|
||||
PaddingRight string
|
||||
PageSeparator string
|
||||
Right string
|
||||
RightSeparator string
|
||||
TopLeft string
|
||||
TopRight string
|
||||
TopSeparator string
|
||||
UnfinishedRow string
|
||||
}
|
||||
|
||||
var (
|
||||
// StyleBoxDefault defines a Boxed-Table like below:
|
||||
// +-----+------------+-----------+--------+-----------------------------+
|
||||
// | # | FIRST NAME | LAST NAME | SALARY | |
|
||||
// +-----+------------+-----------+--------+-----------------------------+
|
||||
// | 1 | Arya | Stark | 3000 | |
|
||||
// | 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! |
|
||||
// | 300 | Tyrion | Lannister | 5000 | |
|
||||
// +-----+------------+-----------+--------+-----------------------------+
|
||||
// | | | TOTAL | 10000 | |
|
||||
// +-----+------------+-----------+--------+-----------------------------+
|
||||
StyleBoxDefault = BoxStyle{
|
||||
BottomLeft: "+",
|
||||
BottomRight: "+",
|
||||
BottomSeparator: "+",
|
||||
EmptySeparator: " ",
|
||||
Left: "|",
|
||||
LeftSeparator: "+",
|
||||
MiddleHorizontal: "-",
|
||||
MiddleSeparator: "+",
|
||||
MiddleVertical: "|",
|
||||
PaddingLeft: " ",
|
||||
PaddingRight: " ",
|
||||
PageSeparator: "\n",
|
||||
Right: "|",
|
||||
RightSeparator: "+",
|
||||
TopLeft: "+",
|
||||
TopRight: "+",
|
||||
TopSeparator: "+",
|
||||
UnfinishedRow: " ~",
|
||||
}
|
||||
|
||||
// StyleBoxBold defines a Boxed-Table like below:
|
||||
// ┏━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ # ┃ FIRST NAME ┃ LAST NAME ┃ SALARY ┃ ┃
|
||||
// ┣━━━━━╋━━━━━━━━━━━━╋━━━━━━━━━━━╋━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ 1 ┃ Arya ┃ Stark ┃ 3000 ┃ ┃
|
||||
// ┃ 20 ┃ Jon ┃ Snow ┃ 2000 ┃ You know nothing, Jon Snow! ┃
|
||||
// ┃ 300 ┃ Tyrion ┃ Lannister ┃ 5000 ┃ ┃
|
||||
// ┣━━━━━╋━━━━━━━━━━━━╋━━━━━━━━━━━╋━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ ┃ ┃ TOTAL ┃ 10000 ┃ ┃
|
||||
// ┗━━━━━┻━━━━━━━━━━━━┻━━━━━━━━━━━┻━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
StyleBoxBold = BoxStyle{
|
||||
BottomLeft: "┗",
|
||||
BottomRight: "┛",
|
||||
BottomSeparator: "┻",
|
||||
EmptySeparator: " ",
|
||||
Left: "┃",
|
||||
LeftSeparator: "┣",
|
||||
MiddleHorizontal: "━",
|
||||
MiddleSeparator: "╋",
|
||||
MiddleVertical: "┃",
|
||||
PaddingLeft: " ",
|
||||
PaddingRight: " ",
|
||||
PageSeparator: "\n",
|
||||
Right: "┃",
|
||||
RightSeparator: "┫",
|
||||
TopLeft: "┏",
|
||||
TopRight: "┓",
|
||||
TopSeparator: "┳",
|
||||
UnfinishedRow: " ≈",
|
||||
}
|
||||
|
||||
// StyleBoxDouble defines a Boxed-Table like below:
|
||||
// ╔═════╦════════════╦═══════════╦════════╦═════════════════════════════╗
|
||||
// ║ # ║ FIRST NAME ║ LAST NAME ║ SALARY ║ ║
|
||||
// ╠═════╬════════════╬═══════════╬════════╬═════════════════════════════╣
|
||||
// ║ 1 ║ Arya ║ Stark ║ 3000 ║ ║
|
||||
// ║ 20 ║ Jon ║ Snow ║ 2000 ║ You know nothing, Jon Snow! ║
|
||||
// ║ 300 ║ Tyrion ║ Lannister ║ 5000 ║ ║
|
||||
// ╠═════╬════════════╬═══════════╬════════╬═════════════════════════════╣
|
||||
// ║ ║ ║ TOTAL ║ 10000 ║ ║
|
||||
// ╚═════╩════════════╩═══════════╩════════╩═════════════════════════════╝
|
||||
StyleBoxDouble = BoxStyle{
|
||||
BottomLeft: "╚",
|
||||
BottomRight: "╝",
|
||||
BottomSeparator: "╩",
|
||||
EmptySeparator: " ",
|
||||
Left: "║",
|
||||
LeftSeparator: "╠",
|
||||
MiddleHorizontal: "═",
|
||||
MiddleSeparator: "╬",
|
||||
MiddleVertical: "║",
|
||||
PaddingLeft: " ",
|
||||
PaddingRight: " ",
|
||||
PageSeparator: "\n",
|
||||
Right: "║",
|
||||
RightSeparator: "╣",
|
||||
TopLeft: "╔",
|
||||
TopRight: "╗",
|
||||
TopSeparator: "╦",
|
||||
UnfinishedRow: " ≈",
|
||||
}
|
||||
|
||||
// StyleBoxLight defines a Boxed-Table like below:
|
||||
// ┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐
|
||||
// │ # │ FIRST NAME │ LAST NAME │ SALARY │ │
|
||||
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
|
||||
// │ 1 │ Arya │ Stark │ 3000 │ │
|
||||
// │ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │
|
||||
// │ 300 │ Tyrion │ Lannister │ 5000 │ │
|
||||
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
|
||||
// │ │ │ TOTAL │ 10000 │ │
|
||||
// └─────┴────────────┴───────────┴────────┴─────────────────────────────┘
|
||||
StyleBoxLight = BoxStyle{
|
||||
BottomLeft: "└",
|
||||
BottomRight: "┘",
|
||||
BottomSeparator: "┴",
|
||||
EmptySeparator: " ",
|
||||
Left: "│",
|
||||
LeftSeparator: "├",
|
||||
MiddleHorizontal: "─",
|
||||
MiddleSeparator: "┼",
|
||||
MiddleVertical: "│",
|
||||
PaddingLeft: " ",
|
||||
PaddingRight: " ",
|
||||
PageSeparator: "\n",
|
||||
Right: "│",
|
||||
RightSeparator: "┤",
|
||||
TopLeft: "┌",
|
||||
TopRight: "┐",
|
||||
TopSeparator: "┬",
|
||||
UnfinishedRow: " ≈",
|
||||
}
|
||||
|
||||
// StyleBoxRounded defines a Boxed-Table like below:
|
||||
// ╭─────┬────────────┬───────────┬────────┬─────────────────────────────╮
|
||||
// │ # │ FIRST NAME │ LAST NAME │ SALARY │ │
|
||||
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
|
||||
// │ 1 │ Arya │ Stark │ 3000 │ │
|
||||
// │ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │
|
||||
// │ 300 │ Tyrion │ Lannister │ 5000 │ │
|
||||
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
|
||||
// │ │ │ TOTAL │ 10000 │ │
|
||||
// ╰─────┴────────────┴───────────┴────────┴─────────────────────────────╯
|
||||
StyleBoxRounded = BoxStyle{
|
||||
BottomLeft: "╰",
|
||||
BottomRight: "╯",
|
||||
BottomSeparator: "┴",
|
||||
EmptySeparator: " ",
|
||||
Left: "│",
|
||||
LeftSeparator: "├",
|
||||
MiddleHorizontal: "─",
|
||||
MiddleSeparator: "┼",
|
||||
MiddleVertical: "│",
|
||||
PaddingLeft: " ",
|
||||
PaddingRight: " ",
|
||||
PageSeparator: "\n",
|
||||
Right: "│",
|
||||
RightSeparator: "┤",
|
||||
TopLeft: "╭",
|
||||
TopRight: "╮",
|
||||
TopSeparator: "┬",
|
||||
UnfinishedRow: " ≈",
|
||||
}
|
||||
|
||||
// styleBoxTest defines a Boxed-Table like below:
|
||||
// (-----^------------^-----------^--------^-----------------------------)
|
||||
// [< #>|<FIRST NAME>|<LAST NAME>|<SALARY>|< >]
|
||||
// {-----+------------+-----------+--------+-----------------------------}
|
||||
// [< 1>|<Arya >|<Stark >|< 3000>|< >]
|
||||
// [< 20>|<Jon >|<Snow >|< 2000>|<You know nothing, Jon Snow!>]
|
||||
// [<300>|<Tyrion >|<Lannister>|< 5000>|< >]
|
||||
// {-----+------------+-----------+--------+-----------------------------}
|
||||
// [< >|< >|<TOTAL >|< 10000>|< >]
|
||||
// \-----v------------v-----------v--------v-----------------------------/
|
||||
styleBoxTest = BoxStyle{
|
||||
BottomLeft: "\\",
|
||||
BottomRight: "/",
|
||||
BottomSeparator: "v",
|
||||
EmptySeparator: " ",
|
||||
Left: "[",
|
||||
LeftSeparator: "{",
|
||||
MiddleHorizontal: "--",
|
||||
MiddleSeparator: "+",
|
||||
MiddleVertical: "|",
|
||||
PaddingLeft: "<",
|
||||
PaddingRight: ">",
|
||||
PageSeparator: "\n",
|
||||
Right: "]",
|
||||
RightSeparator: "}",
|
||||
TopLeft: "(",
|
||||
TopRight: ")",
|
||||
TopSeparator: "^",
|
||||
UnfinishedRow: " ~~~",
|
||||
}
|
||||
)
|
||||
|
||||
// ColorOptions defines the ANSI colors to use for parts of the Table.
|
||||
type ColorOptions struct {
|
||||
Border text.Colors // borders (if nil, uses one of the below)
|
||||
Footer text.Colors // footer row(s) colors
|
||||
Header text.Colors // header row(s) colors
|
||||
IndexColumn text.Colors // index-column colors (row #, etc.)
|
||||
Row text.Colors // regular row(s) colors
|
||||
RowAlternate text.Colors // regular row(s) colors for the even-numbered rows
|
||||
Separator text.Colors // separators (if nil, uses one of the above)
|
||||
}
|
||||
|
||||
var (
|
||||
// ColorOptionsDefault defines sensible ANSI color options - basically NONE.
|
||||
ColorOptionsDefault = ColorOptions{}
|
||||
|
||||
// ColorOptionsBright renders dark text on bright background.
|
||||
ColorOptionsBright = ColorOptionsBlackOnCyanWhite
|
||||
|
||||
// ColorOptionsDark renders bright text on dark background.
|
||||
ColorOptionsDark = ColorOptionsCyanWhiteOnBlack
|
||||
|
||||
// ColorOptionsBlackOnBlueWhite renders Black text on Blue/White background.
|
||||
ColorOptionsBlackOnBlueWhite = ColorOptions{
|
||||
Footer: text.Colors{text.BgBlue, text.FgBlack},
|
||||
Header: text.Colors{text.BgHiBlue, text.FgBlack},
|
||||
IndexColumn: text.Colors{text.BgHiBlue, text.FgBlack},
|
||||
Row: text.Colors{text.BgHiWhite, text.FgBlack},
|
||||
RowAlternate: text.Colors{text.BgWhite, text.FgBlack},
|
||||
}
|
||||
|
||||
// ColorOptionsBlackOnCyanWhite renders Black text on Cyan/White background.
|
||||
ColorOptionsBlackOnCyanWhite = ColorOptions{
|
||||
Footer: text.Colors{text.BgCyan, text.FgBlack},
|
||||
Header: text.Colors{text.BgHiCyan, text.FgBlack},
|
||||
IndexColumn: text.Colors{text.BgHiCyan, text.FgBlack},
|
||||
Row: text.Colors{text.BgHiWhite, text.FgBlack},
|
||||
RowAlternate: text.Colors{text.BgWhite, text.FgBlack},
|
||||
}
|
||||
|
||||
// ColorOptionsBlackOnGreenWhite renders Black text on Green/White
|
||||
// background.
|
||||
ColorOptionsBlackOnGreenWhite = ColorOptions{
|
||||
Footer: text.Colors{text.BgGreen, text.FgBlack},
|
||||
Header: text.Colors{text.BgHiGreen, text.FgBlack},
|
||||
IndexColumn: text.Colors{text.BgHiGreen, text.FgBlack},
|
||||
Row: text.Colors{text.BgHiWhite, text.FgBlack},
|
||||
RowAlternate: text.Colors{text.BgWhite, text.FgBlack},
|
||||
}
|
||||
|
||||
// ColorOptionsBlackOnMagentaWhite renders Black text on Magenta/White
|
||||
// background.
|
||||
ColorOptionsBlackOnMagentaWhite = ColorOptions{
|
||||
Footer: text.Colors{text.BgMagenta, text.FgBlack},
|
||||
Header: text.Colors{text.BgHiMagenta, text.FgBlack},
|
||||
IndexColumn: text.Colors{text.BgHiMagenta, text.FgBlack},
|
||||
Row: text.Colors{text.BgHiWhite, text.FgBlack},
|
||||
RowAlternate: text.Colors{text.BgWhite, text.FgBlack},
|
||||
}
|
||||
|
||||
// ColorOptionsBlackOnRedWhite renders Black text on Red/White background.
|
||||
ColorOptionsBlackOnRedWhite = ColorOptions{
|
||||
Footer: text.Colors{text.BgRed, text.FgBlack},
|
||||
Header: text.Colors{text.BgHiRed, text.FgBlack},
|
||||
IndexColumn: text.Colors{text.BgHiRed, text.FgBlack},
|
||||
Row: text.Colors{text.BgHiWhite, text.FgBlack},
|
||||
RowAlternate: text.Colors{text.BgWhite, text.FgBlack},
|
||||
}
|
||||
|
||||
// ColorOptionsBlackOnYellowWhite renders Black text on Yellow/White
|
||||
// background.
|
||||
ColorOptionsBlackOnYellowWhite = ColorOptions{
|
||||
Footer: text.Colors{text.BgYellow, text.FgBlack},
|
||||
Header: text.Colors{text.BgHiYellow, text.FgBlack},
|
||||
IndexColumn: text.Colors{text.BgHiYellow, text.FgBlack},
|
||||
Row: text.Colors{text.BgHiWhite, text.FgBlack},
|
||||
RowAlternate: text.Colors{text.BgWhite, text.FgBlack},
|
||||
}
|
||||
|
||||
// ColorOptionsBlueWhiteOnBlack renders Blue/White text on Black background.
|
||||
ColorOptionsBlueWhiteOnBlack = ColorOptions{
|
||||
Footer: text.Colors{text.FgBlue, text.BgHiBlack},
|
||||
Header: text.Colors{text.FgHiBlue, text.BgHiBlack},
|
||||
IndexColumn: text.Colors{text.FgHiBlue, text.BgHiBlack},
|
||||
Row: text.Colors{text.FgHiWhite, text.BgBlack},
|
||||
RowAlternate: text.Colors{text.FgWhite, text.BgBlack},
|
||||
}
|
||||
|
||||
// ColorOptionsCyanWhiteOnBlack renders Cyan/White text on Black background.
|
||||
ColorOptionsCyanWhiteOnBlack = ColorOptions{
|
||||
Footer: text.Colors{text.FgCyan, text.BgHiBlack},
|
||||
Header: text.Colors{text.FgHiCyan, text.BgHiBlack},
|
||||
IndexColumn: text.Colors{text.FgHiCyan, text.BgHiBlack},
|
||||
Row: text.Colors{text.FgHiWhite, text.BgBlack},
|
||||
RowAlternate: text.Colors{text.FgWhite, text.BgBlack},
|
||||
}
|
||||
|
||||
// ColorOptionsGreenWhiteOnBlack renders Green/White text on Black
|
||||
// background.
|
||||
ColorOptionsGreenWhiteOnBlack = ColorOptions{
|
||||
Footer: text.Colors{text.FgGreen, text.BgHiBlack},
|
||||
Header: text.Colors{text.FgHiGreen, text.BgHiBlack},
|
||||
IndexColumn: text.Colors{text.FgHiGreen, text.BgHiBlack},
|
||||
Row: text.Colors{text.FgHiWhite, text.BgBlack},
|
||||
RowAlternate: text.Colors{text.FgWhite, text.BgBlack},
|
||||
}
|
||||
|
||||
// ColorOptionsMagentaWhiteOnBlack renders Magenta/White text on Black
|
||||
// background.
|
||||
ColorOptionsMagentaWhiteOnBlack = ColorOptions{
|
||||
Footer: text.Colors{text.FgMagenta, text.BgHiBlack},
|
||||
Header: text.Colors{text.FgHiMagenta, text.BgHiBlack},
|
||||
IndexColumn: text.Colors{text.FgHiMagenta, text.BgHiBlack},
|
||||
Row: text.Colors{text.FgHiWhite, text.BgBlack},
|
||||
RowAlternate: text.Colors{text.FgWhite, text.BgBlack},
|
||||
}
|
||||
|
||||
// ColorOptionsRedWhiteOnBlack renders Red/White text on Black background.
|
||||
ColorOptionsRedWhiteOnBlack = ColorOptions{
|
||||
Footer: text.Colors{text.FgRed, text.BgHiBlack},
|
||||
Header: text.Colors{text.FgHiRed, text.BgHiBlack},
|
||||
IndexColumn: text.Colors{text.FgHiRed, text.BgHiBlack},
|
||||
Row: text.Colors{text.FgHiWhite, text.BgBlack},
|
||||
RowAlternate: text.Colors{text.FgWhite, text.BgBlack},
|
||||
}
|
||||
|
||||
// ColorOptionsYellowWhiteOnBlack renders Yellow/White text on Black
|
||||
// background.
|
||||
ColorOptionsYellowWhiteOnBlack = ColorOptions{
|
||||
Footer: text.Colors{text.FgYellow, text.BgHiBlack},
|
||||
Header: text.Colors{text.FgHiYellow, text.BgHiBlack},
|
||||
IndexColumn: text.Colors{text.FgHiYellow, text.BgHiBlack},
|
||||
Row: text.Colors{text.FgHiWhite, text.BgBlack},
|
||||
RowAlternate: text.Colors{text.FgWhite, text.BgBlack},
|
||||
}
|
||||
)
|
||||
|
||||
// FormatOptions defines the text-formatting to perform on parts of the Table.
|
||||
type FormatOptions struct {
|
||||
Direction text.Direction // (forced) BiDi direction for each Column
|
||||
Footer text.Format // default text format
|
||||
FooterAlign text.Align // default horizontal align
|
||||
FooterVAlign text.VAlign // default vertical align
|
||||
Header text.Format // default text format
|
||||
HeaderAlign text.Align // default horizontal align
|
||||
HeaderVAlign text.VAlign // default vertical align
|
||||
Row text.Format // default text format
|
||||
RowAlign text.Align // default horizontal align
|
||||
RowVAlign text.VAlign // default vertical align
|
||||
}
|
||||
|
||||
// FormatOptionsDefault defines sensible formatting options.
|
||||
var FormatOptionsDefault = FormatOptions{
|
||||
Footer: text.FormatUpper,
|
||||
FooterAlign: text.AlignDefault,
|
||||
FooterVAlign: text.VAlignDefault,
|
||||
Header: text.FormatUpper,
|
||||
HeaderAlign: text.AlignDefault,
|
||||
HeaderVAlign: text.VAlignDefault,
|
||||
Row: text.FormatDefault,
|
||||
RowAlign: text.AlignDefault,
|
||||
RowVAlign: text.VAlignDefault,
|
||||
}
|
||||
|
||||
// HTMLOptions defines the global options to control HTML rendering.
|
||||
type HTMLOptions struct {
|
||||
CSSClass string // CSS class to set on the overall <table> tag
|
||||
EmptyColumn string // string to replace "" columns with (entire content being "")
|
||||
EscapeText bool // escape text into HTML-safe content?
|
||||
Newline string // string to replace "\n" characters with
|
||||
}
|
||||
|
||||
// DefaultHTMLOptions defines sensible HTML rendering defaults.
|
||||
var DefaultHTMLOptions = HTMLOptions{
|
||||
CSSClass: DefaultHTMLCSSClass,
|
||||
EmptyColumn: " ",
|
||||
EscapeText: true,
|
||||
Newline: "<br/>",
|
||||
}
|
||||
|
||||
// Options defines the global options that determine how the Table is
|
||||
// rendered.
|
||||
type Options struct {
|
||||
// DoNotColorBordersAndSeparators disables coloring all the borders and row
|
||||
// or column separators.
|
||||
DoNotColorBordersAndSeparators bool
|
||||
|
||||
// DrawBorder enables or disables drawing the border around the Table.
|
||||
// Example of a table where it is disabled:
|
||||
// # │ FIRST NAME │ LAST NAME │ SALARY │
|
||||
// ─────┼────────────┼───────────┼────────┼─────────────────────────────
|
||||
// 1 │ Arya │ Stark │ 3000 │
|
||||
// 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow!
|
||||
// 300 │ Tyrion │ Lannister │ 5000 │
|
||||
// ─────┼────────────┼───────────┼────────┼─────────────────────────────
|
||||
// │ │ TOTAL │ 10000 │
|
||||
DrawBorder bool
|
||||
|
||||
// SeparateColumns enables or disable drawing border between columns.
|
||||
// Example of a table where it is disabled:
|
||||
// ┌─────────────────────────────────────────────────────────────────┐
|
||||
// │ # FIRST NAME LAST NAME SALARY │
|
||||
// ├─────────────────────────────────────────────────────────────────┤
|
||||
// │ 1 Arya Stark 3000 │
|
||||
// │ 20 Jon Snow 2000 You know nothing, Jon Snow! │
|
||||
// │ 300 Tyrion Lannister 5000 │
|
||||
// │ TOTAL 10000 │
|
||||
// └─────────────────────────────────────────────────────────────────┘
|
||||
SeparateColumns bool
|
||||
|
||||
// SeparateFooter enables or disable drawing border between the footer and
|
||||
// the rows. Example of a table where it is disabled:
|
||||
// ┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐
|
||||
// │ # │ FIRST NAME │ LAST NAME │ SALARY │ │
|
||||
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
|
||||
// │ 1 │ Arya │ Stark │ 3000 │ │
|
||||
// │ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │
|
||||
// │ 300 │ Tyrion │ Lannister │ 5000 │ │
|
||||
// │ │ │ TOTAL │ 10000 │ │
|
||||
// └─────┴────────────┴───────────┴────────┴─────────────────────────────┘
|
||||
SeparateFooter bool
|
||||
|
||||
// SeparateHeader enables or disable drawing border between the header and
|
||||
// the rows. Example of a table where it is disabled:
|
||||
// ┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐
|
||||
// │ # │ FIRST NAME │ LAST NAME │ SALARY │ │
|
||||
// │ 1 │ Arya │ Stark │ 3000 │ │
|
||||
// │ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │
|
||||
// │ 300 │ Tyrion │ Lannister │ 5000 │ │
|
||||
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
|
||||
// │ │ │ TOTAL │ 10000 │ │
|
||||
// └─────┴────────────┴───────────┴────────┴─────────────────────────────┘
|
||||
SeparateHeader bool
|
||||
|
||||
// SeparateRows enables or disables drawing separators between each row.
|
||||
// Example of a table where it is enabled:
|
||||
// ┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐
|
||||
// │ # │ FIRST NAME │ LAST NAME │ SALARY │ │
|
||||
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
|
||||
// │ 1 │ Arya │ Stark │ 3000 │ │
|
||||
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
|
||||
// │ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │
|
||||
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
|
||||
// │ 300 │ Tyrion │ Lannister │ 5000 │ │
|
||||
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
|
||||
// │ │ │ TOTAL │ 10000 │ │
|
||||
// └─────┴────────────┴───────────┴────────┴─────────────────────────────┘
|
||||
SeparateRows bool
|
||||
}
|
||||
|
||||
var (
|
||||
// OptionsDefault defines sensible global options.
|
||||
OptionsDefault = Options{
|
||||
DrawBorder: true,
|
||||
SeparateColumns: true,
|
||||
SeparateFooter: true,
|
||||
SeparateHeader: true,
|
||||
SeparateRows: false,
|
||||
}
|
||||
|
||||
// OptionsNoBorders sets up a table without any borders.
|
||||
OptionsNoBorders = Options{
|
||||
DrawBorder: false,
|
||||
SeparateColumns: true,
|
||||
SeparateFooter: true,
|
||||
SeparateHeader: true,
|
||||
SeparateRows: false,
|
||||
}
|
||||
|
||||
// OptionsNoBordersAndSeparators sets up a table without any borders or
|
||||
// separators.
|
||||
OptionsNoBordersAndSeparators = Options{
|
||||
DrawBorder: false,
|
||||
SeparateColumns: false,
|
||||
SeparateFooter: false,
|
||||
SeparateHeader: false,
|
||||
SeparateRows: false,
|
||||
}
|
||||
)
|
||||
|
||||
// SizeOptions defines the way to control the width of the table output.
|
||||
type SizeOptions struct {
|
||||
// WidthMax is the maximum allotted width for the full row;
|
||||
// any content beyond this will be truncated using the text
|
||||
// in Style.Box.UnfinishedRow
|
||||
WidthMax int
|
||||
// WidthMin is the minimum allotted width for the full row;
|
||||
// columns will be auto-expanded until the overall width
|
||||
// is met
|
||||
WidthMin int
|
||||
}
|
||||
|
||||
var (
|
||||
// SizeOptionsDefault defines sensible size options - basically NONE.
|
||||
SizeOptionsDefault = SizeOptions{
|
||||
WidthMax: 0,
|
||||
WidthMin: 0,
|
||||
}
|
||||
)
|
||||
|
||||
// TitleOptions defines the way the title text is to be rendered.
|
||||
type TitleOptions struct {
|
||||
Align text.Align
|
||||
Colors text.Colors
|
||||
Format text.Format
|
||||
}
|
||||
|
||||
var (
|
||||
// TitleOptionsDefault defines sensible title options - basically NONE.
|
||||
TitleOptionsDefault = TitleOptions{}
|
||||
|
||||
// TitleOptionsBright renders Bright Bold text on Dark background.
|
||||
TitleOptionsBright = TitleOptionsBlackOnCyan
|
||||
|
||||
// TitleOptionsDark renders Dark Bold text on Bright background.
|
||||
TitleOptionsDark = TitleOptionsCyanOnBlack
|
||||
|
||||
// TitleOptionsBlackOnBlue renders Black text on Blue background.
|
||||
TitleOptionsBlackOnBlue = TitleOptions{
|
||||
Colors: append(ColorOptionsBlackOnBlueWhite.Header, text.Bold),
|
||||
}
|
||||
|
||||
// TitleOptionsBlackOnCyan renders Black Bold text on Cyan background.
|
||||
TitleOptionsBlackOnCyan = TitleOptions{
|
||||
Colors: append(ColorOptionsBlackOnCyanWhite.Header, text.Bold),
|
||||
}
|
||||
|
||||
// TitleOptionsBlackOnGreen renders Black Bold text onGreen background.
|
||||
TitleOptionsBlackOnGreen = TitleOptions{
|
||||
Colors: append(ColorOptionsBlackOnGreenWhite.Header, text.Bold),
|
||||
}
|
||||
|
||||
// TitleOptionsBlackOnMagenta renders Black Bold text on Magenta background.
|
||||
TitleOptionsBlackOnMagenta = TitleOptions{
|
||||
Colors: append(ColorOptionsBlackOnMagentaWhite.Header, text.Bold),
|
||||
}
|
||||
|
||||
// TitleOptionsBlackOnRed renders Black Bold text on Red background.
|
||||
TitleOptionsBlackOnRed = TitleOptions{
|
||||
Colors: append(ColorOptionsBlackOnRedWhite.Header, text.Bold),
|
||||
}
|
||||
|
||||
// TitleOptionsBlackOnYellow renders Black Bold text on Yellow background.
|
||||
TitleOptionsBlackOnYellow = TitleOptions{
|
||||
Colors: append(ColorOptionsBlackOnYellowWhite.Header, text.Bold),
|
||||
}
|
||||
|
||||
// TitleOptionsBlueOnBlack renders Blue Bold text on Black background.
|
||||
TitleOptionsBlueOnBlack = TitleOptions{
|
||||
Colors: append(ColorOptionsBlueWhiteOnBlack.Header, text.Bold),
|
||||
}
|
||||
|
||||
// TitleOptionsCyanOnBlack renders Cyan Bold text on Black background.
|
||||
TitleOptionsCyanOnBlack = TitleOptions{
|
||||
Colors: append(ColorOptionsCyanWhiteOnBlack.Header, text.Bold),
|
||||
}
|
||||
|
||||
// TitleOptionsGreenOnBlack renders Green Bold text on Black background.
|
||||
TitleOptionsGreenOnBlack = TitleOptions{
|
||||
Colors: append(ColorOptionsGreenWhiteOnBlack.Header, text.Bold),
|
||||
}
|
||||
|
||||
// TitleOptionsMagentaOnBlack renders Magenta Bold text on Black background.
|
||||
TitleOptionsMagentaOnBlack = TitleOptions{
|
||||
Colors: append(ColorOptionsMagentaWhiteOnBlack.Header, text.Bold),
|
||||
}
|
||||
|
||||
// TitleOptionsRedOnBlack renders Red Bold text on Black background.
|
||||
TitleOptionsRedOnBlack = TitleOptions{
|
||||
Colors: append(ColorOptionsRedWhiteOnBlack.Header, text.Bold),
|
||||
}
|
||||
|
||||
// TitleOptionsYellowOnBlack renders Yellow Bold text on Black background.
|
||||
TitleOptionsYellowOnBlack = TitleOptions{
|
||||
Colors: append(ColorOptionsYellowWhiteOnBlack.Header, text.Bold),
|
||||
}
|
||||
)
|
||||
+303
@@ -0,0 +1,303 @@
|
||||
package table
|
||||
|
||||
// BoxStyle defines the characters/strings to use to render the borders and
|
||||
// separators for the Table.
|
||||
type BoxStyle struct {
|
||||
BottomLeft string
|
||||
BottomRight string
|
||||
BottomSeparator string
|
||||
EmptySeparator string
|
||||
Left string
|
||||
LeftSeparator string
|
||||
MiddleHorizontal string
|
||||
MiddleSeparator string
|
||||
MiddleVertical string
|
||||
PaddingLeft string
|
||||
PaddingRight string
|
||||
PageSeparator string
|
||||
Right string
|
||||
RightSeparator string
|
||||
TopLeft string
|
||||
TopRight string
|
||||
TopSeparator string
|
||||
UnfinishedRow string
|
||||
|
||||
// Horizontal lets you customize the horizontal lines for the Table
|
||||
// in a more granular way than the MiddleHorizontal string. Setting
|
||||
// this to a non-nil value will override MiddleHorizontal.
|
||||
Horizontal *BoxStyleHorizontal
|
||||
}
|
||||
|
||||
// BoxStyleHorizontal defines the characters/strings to use to render the
|
||||
// horizontal lines for the Table.
|
||||
type BoxStyleHorizontal struct {
|
||||
TitleTop string
|
||||
TitleBottom string // overrides HeaderTop/RowTop
|
||||
HeaderTop string
|
||||
HeaderMiddle string
|
||||
HeaderBottom string // overrides RowTop
|
||||
RowTop string
|
||||
RowMiddle string
|
||||
RowBottom string
|
||||
FooterTop string // overrides RowBottom
|
||||
FooterMiddle string
|
||||
FooterBottom string
|
||||
}
|
||||
|
||||
// NewBoxStyleHorizontal creates a new BoxStyleHorizontal with the given
|
||||
// horizontal string.
|
||||
func NewBoxStyleHorizontal(horizontal string) *BoxStyleHorizontal {
|
||||
return &BoxStyleHorizontal{
|
||||
TitleTop: horizontal,
|
||||
TitleBottom: horizontal,
|
||||
HeaderTop: horizontal,
|
||||
HeaderMiddle: horizontal,
|
||||
HeaderBottom: horizontal,
|
||||
RowTop: horizontal,
|
||||
RowMiddle: horizontal,
|
||||
RowBottom: horizontal,
|
||||
FooterTop: horizontal,
|
||||
FooterMiddle: horizontal,
|
||||
FooterBottom: horizontal,
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
// StyleBoxDefault defines a Boxed-Table like below:
|
||||
// +-----+------------+-----------+--------+-----------------------------+
|
||||
// | # | FIRST NAME | LAST NAME | SALARY | |
|
||||
// +-----+------------+-----------+--------+-----------------------------+
|
||||
// | 1 | Arya | Stark | 3000 | |
|
||||
// | 20 | Jon | Snow | 2000 | You know nothing, Jon Snow! |
|
||||
// | 300 | Tyrion | Lannister | 5000 | |
|
||||
// +-----+------------+-----------+--------+-----------------------------+
|
||||
// | | | TOTAL | 10000 | |
|
||||
// +-----+------------+-----------+--------+-----------------------------+
|
||||
StyleBoxDefault = BoxStyle{
|
||||
BottomLeft: "+",
|
||||
BottomRight: "+",
|
||||
BottomSeparator: "+",
|
||||
EmptySeparator: " ",
|
||||
Left: "|",
|
||||
LeftSeparator: "+",
|
||||
MiddleHorizontal: "-",
|
||||
MiddleSeparator: "+",
|
||||
MiddleVertical: "|",
|
||||
PaddingLeft: " ",
|
||||
PaddingRight: " ",
|
||||
PageSeparator: "\n",
|
||||
Right: "|",
|
||||
RightSeparator: "+",
|
||||
TopLeft: "+",
|
||||
TopRight: "+",
|
||||
TopSeparator: "+",
|
||||
UnfinishedRow: " ~",
|
||||
}
|
||||
|
||||
// StyleBoxBold defines a Boxed-Table like below:
|
||||
// ┏━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
// ┃ # ┃ FIRST NAME ┃ LAST NAME ┃ SALARY ┃ ┃
|
||||
// ┣━━━━━╋━━━━━━━━━━━━╋━━━━━━━━━━━╋━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ 1 ┃ Arya ┃ Stark ┃ 3000 ┃ ┃
|
||||
// ┃ 20 ┃ Jon ┃ Snow ┃ 2000 ┃ You know nothing, Jon Snow! ┃
|
||||
// ┃ 300 ┃ Tyrion ┃ Lannister ┃ 5000 ┃ ┃
|
||||
// ┣━━━━━╋━━━━━━━━━━━━╋━━━━━━━━━━━╋━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
|
||||
// ┃ ┃ ┃ TOTAL ┃ 10000 ┃ ┃
|
||||
// ┗━━━━━┻━━━━━━━━━━━━┻━━━━━━━━━━━┻━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
|
||||
StyleBoxBold = BoxStyle{
|
||||
BottomLeft: "┗",
|
||||
BottomRight: "┛",
|
||||
BottomSeparator: "┻",
|
||||
EmptySeparator: " ",
|
||||
Left: "┃",
|
||||
LeftSeparator: "┣",
|
||||
MiddleHorizontal: "━",
|
||||
MiddleSeparator: "╋",
|
||||
MiddleVertical: "┃",
|
||||
PaddingLeft: " ",
|
||||
PaddingRight: " ",
|
||||
PageSeparator: "\n",
|
||||
Right: "┃",
|
||||
RightSeparator: "┫",
|
||||
TopLeft: "┏",
|
||||
TopRight: "┓",
|
||||
TopSeparator: "┳",
|
||||
UnfinishedRow: " ≈",
|
||||
}
|
||||
|
||||
// StyleBoxDouble defines a Boxed-Table like below:
|
||||
// ╔═════╦════════════╦═══════════╦════════╦═════════════════════════════╗
|
||||
// ║ # ║ FIRST NAME ║ LAST NAME ║ SALARY ║ ║
|
||||
// ╠═════╬════════════╬═══════════╬════════╬═════════════════════════════╣
|
||||
// ║ 1 ║ Arya ║ Stark ║ 3000 ║ ║
|
||||
// ║ 20 ║ Jon ║ Snow ║ 2000 ║ You know nothing, Jon Snow! ║
|
||||
// ║ 300 ║ Tyrion ║ Lannister ║ 5000 ║ ║
|
||||
// ╠═════╬════════════╬═══════════╬════════╬═════════════════════════════╣
|
||||
// ║ ║ ║ TOTAL ║ 10000 ║ ║
|
||||
// ╚═════╩════════════╩═══════════╩════════╩═════════════════════════════╝
|
||||
StyleBoxDouble = BoxStyle{
|
||||
BottomLeft: "╚",
|
||||
BottomRight: "╝",
|
||||
BottomSeparator: "╩",
|
||||
EmptySeparator: " ",
|
||||
Left: "║",
|
||||
LeftSeparator: "╠",
|
||||
MiddleHorizontal: "═",
|
||||
MiddleSeparator: "╬",
|
||||
MiddleVertical: "║",
|
||||
PaddingLeft: " ",
|
||||
PaddingRight: " ",
|
||||
PageSeparator: "\n",
|
||||
Right: "║",
|
||||
RightSeparator: "╣",
|
||||
TopLeft: "╔",
|
||||
TopRight: "╗",
|
||||
TopSeparator: "╦",
|
||||
UnfinishedRow: " ≈",
|
||||
}
|
||||
|
||||
// StyleBoxLight defines a Boxed-Table like below:
|
||||
// ┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐
|
||||
// │ # │ FIRST NAME │ LAST NAME │ SALARY │ │
|
||||
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
|
||||
// │ 1 │ Arya │ Stark │ 3000 │ │
|
||||
// │ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │
|
||||
// │ 300 │ Tyrion │ Lannister │ 5000 │ │
|
||||
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
|
||||
// │ │ │ TOTAL │ 10000 │ │
|
||||
// └─────┴────────────┴───────────┴────────┴─────────────────────────────┘
|
||||
StyleBoxLight = BoxStyle{
|
||||
BottomLeft: "└",
|
||||
BottomRight: "┘",
|
||||
BottomSeparator: "┴",
|
||||
EmptySeparator: " ",
|
||||
Left: "│",
|
||||
LeftSeparator: "├",
|
||||
MiddleHorizontal: "─",
|
||||
MiddleSeparator: "┼",
|
||||
MiddleVertical: "│",
|
||||
PaddingLeft: " ",
|
||||
PaddingRight: " ",
|
||||
PageSeparator: "\n",
|
||||
Right: "│",
|
||||
RightSeparator: "┤",
|
||||
TopLeft: "┌",
|
||||
TopRight: "┐",
|
||||
TopSeparator: "┬",
|
||||
UnfinishedRow: " ≈",
|
||||
}
|
||||
|
||||
// StyleBoxRounded defines a Boxed-Table like below:
|
||||
// ╭─────┬────────────┬───────────┬────────┬─────────────────────────────╮
|
||||
// │ # │ FIRST NAME │ LAST NAME │ SALARY │ │
|
||||
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
|
||||
// │ 1 │ Arya │ Stark │ 3000 │ │
|
||||
// │ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │
|
||||
// │ 300 │ Tyrion │ Lannister │ 5000 │ │
|
||||
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
|
||||
// │ │ │ TOTAL │ 10000 │ │
|
||||
// ╰─────┴────────────┴───────────┴────────┴─────────────────────────────╯
|
||||
StyleBoxRounded = BoxStyle{
|
||||
BottomLeft: "╰",
|
||||
BottomRight: "╯",
|
||||
BottomSeparator: "┴",
|
||||
EmptySeparator: " ",
|
||||
Left: "│",
|
||||
LeftSeparator: "├",
|
||||
MiddleHorizontal: "─",
|
||||
MiddleSeparator: "┼",
|
||||
MiddleVertical: "│",
|
||||
PaddingLeft: " ",
|
||||
PaddingRight: " ",
|
||||
PageSeparator: "\n",
|
||||
Right: "│",
|
||||
RightSeparator: "┤",
|
||||
TopLeft: "╭",
|
||||
TopRight: "╮",
|
||||
TopSeparator: "┬",
|
||||
UnfinishedRow: " ≈",
|
||||
}
|
||||
|
||||
// styleBoxTest defines a Boxed-Table like below:
|
||||
// (-----^------------^-----------^--------^-----------------------------)
|
||||
// [< #>|<FIRST NAME>|<LAST NAME>|<SALARY>|< >]
|
||||
// {-----+------------+-----------+--------+-----------------------------}
|
||||
// [< 1>|<Arya >|<Stark >|< 3000>|< >]
|
||||
// [< 20>|<Jon >|<Snow >|< 2000>|<You know nothing, Jon Snow!>]
|
||||
// [<300>|<Tyrion >|<Lannister>|< 5000>|< >]
|
||||
// {-----+------------+-----------+--------+-----------------------------}
|
||||
// [< >|< >|<TOTAL >|< 10000>|< >]
|
||||
// \-----v------------v-----------v--------v-----------------------------/
|
||||
styleBoxTest = BoxStyle{
|
||||
BottomLeft: "\\",
|
||||
BottomRight: "/",
|
||||
BottomSeparator: "v",
|
||||
EmptySeparator: " ",
|
||||
Left: "[",
|
||||
LeftSeparator: "{",
|
||||
MiddleHorizontal: "--",
|
||||
MiddleSeparator: "+",
|
||||
MiddleVertical: "|",
|
||||
PaddingLeft: "<",
|
||||
PaddingRight: ">",
|
||||
PageSeparator: "\n",
|
||||
Right: "]",
|
||||
RightSeparator: "}",
|
||||
TopLeft: "(",
|
||||
TopRight: ")",
|
||||
TopSeparator: "^",
|
||||
UnfinishedRow: " ~~~",
|
||||
}
|
||||
)
|
||||
|
||||
type separatorType int
|
||||
|
||||
const (
|
||||
separatorTypeTitleTop separatorType = iota
|
||||
separatorTypeTitleBottom
|
||||
separatorTypeHeaderTop
|
||||
separatorTypeHeaderMiddle
|
||||
separatorTypeHeaderBottom
|
||||
separatorTypeRowTop
|
||||
separatorTypeRowMiddle
|
||||
separatorTypeRowBottom
|
||||
separatorTypeFooterTop
|
||||
separatorTypeFooterMiddle
|
||||
separatorTypeFooterBottom
|
||||
separatorTypeCount // this should be the last value
|
||||
)
|
||||
|
||||
func (bs *BoxStyle) ensureHorizontalInitialized() {
|
||||
if bs.Horizontal == nil {
|
||||
bs.Horizontal = NewBoxStyleHorizontal(bs.MiddleHorizontal)
|
||||
}
|
||||
}
|
||||
|
||||
func (bs *BoxStyle) middleHorizontal(st separatorType) string {
|
||||
bs.ensureHorizontalInitialized()
|
||||
|
||||
switch st {
|
||||
case separatorTypeTitleTop:
|
||||
return bs.Horizontal.TitleTop
|
||||
case separatorTypeTitleBottom:
|
||||
return bs.Horizontal.TitleBottom
|
||||
case separatorTypeHeaderTop:
|
||||
return bs.Horizontal.HeaderTop
|
||||
case separatorTypeHeaderMiddle:
|
||||
return bs.Horizontal.HeaderMiddle
|
||||
case separatorTypeHeaderBottom:
|
||||
return bs.Horizontal.HeaderBottom
|
||||
case separatorTypeRowTop:
|
||||
return bs.Horizontal.RowTop
|
||||
case separatorTypeRowBottom:
|
||||
return bs.Horizontal.RowBottom
|
||||
case separatorTypeFooterTop:
|
||||
return bs.Horizontal.FooterTop
|
||||
case separatorTypeFooterMiddle:
|
||||
return bs.Horizontal.FooterMiddle
|
||||
case separatorTypeFooterBottom:
|
||||
return bs.Horizontal.FooterBottom
|
||||
default:
|
||||
return bs.Horizontal.RowMiddle
|
||||
}
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
package table
|
||||
|
||||
import "github.com/jedib0t/go-pretty/v6/text"
|
||||
|
||||
// ColorOptions defines the ANSI colors to use for parts of the Table.
|
||||
type ColorOptions struct {
|
||||
Border text.Colors // borders (if nil, uses one of the below)
|
||||
Footer text.Colors // footer row(s) colors
|
||||
Header text.Colors // header row(s) colors
|
||||
IndexColumn text.Colors // index-column colors (row #, etc.)
|
||||
Row text.Colors // regular row(s) colors
|
||||
RowAlternate text.Colors // regular row(s) colors for the even-numbered rows
|
||||
Separator text.Colors // separators (if nil, uses one of the above)
|
||||
}
|
||||
|
||||
var (
|
||||
// ColorOptionsDefault defines sensible ANSI color options - basically NONE.
|
||||
ColorOptionsDefault = ColorOptions{}
|
||||
|
||||
// ColorOptionsBright renders dark text on bright background.
|
||||
ColorOptionsBright = ColorOptionsBlackOnCyanWhite
|
||||
|
||||
// ColorOptionsDark renders bright text on dark background.
|
||||
ColorOptionsDark = ColorOptionsCyanWhiteOnBlack
|
||||
|
||||
// ColorOptionsBlackOnBlueWhite renders Black text on Blue/White background.
|
||||
ColorOptionsBlackOnBlueWhite = ColorOptions{
|
||||
Footer: text.Colors{text.BgBlue, text.FgBlack},
|
||||
Header: text.Colors{text.BgHiBlue, text.FgBlack},
|
||||
IndexColumn: text.Colors{text.BgHiBlue, text.FgBlack},
|
||||
Row: text.Colors{text.BgHiWhite, text.FgBlack},
|
||||
RowAlternate: text.Colors{text.BgWhite, text.FgBlack},
|
||||
}
|
||||
|
||||
// ColorOptionsBlackOnCyanWhite renders Black text on Cyan/White background.
|
||||
ColorOptionsBlackOnCyanWhite = ColorOptions{
|
||||
Footer: text.Colors{text.BgCyan, text.FgBlack},
|
||||
Header: text.Colors{text.BgHiCyan, text.FgBlack},
|
||||
IndexColumn: text.Colors{text.BgHiCyan, text.FgBlack},
|
||||
Row: text.Colors{text.BgHiWhite, text.FgBlack},
|
||||
RowAlternate: text.Colors{text.BgWhite, text.FgBlack},
|
||||
}
|
||||
|
||||
// ColorOptionsBlackOnGreenWhite renders Black text on Green/White
|
||||
// background.
|
||||
ColorOptionsBlackOnGreenWhite = ColorOptions{
|
||||
Footer: text.Colors{text.BgGreen, text.FgBlack},
|
||||
Header: text.Colors{text.BgHiGreen, text.FgBlack},
|
||||
IndexColumn: text.Colors{text.BgHiGreen, text.FgBlack},
|
||||
Row: text.Colors{text.BgHiWhite, text.FgBlack},
|
||||
RowAlternate: text.Colors{text.BgWhite, text.FgBlack},
|
||||
}
|
||||
|
||||
// ColorOptionsBlackOnMagentaWhite renders Black text on Magenta/White
|
||||
// background.
|
||||
ColorOptionsBlackOnMagentaWhite = ColorOptions{
|
||||
Footer: text.Colors{text.BgMagenta, text.FgBlack},
|
||||
Header: text.Colors{text.BgHiMagenta, text.FgBlack},
|
||||
IndexColumn: text.Colors{text.BgHiMagenta, text.FgBlack},
|
||||
Row: text.Colors{text.BgHiWhite, text.FgBlack},
|
||||
RowAlternate: text.Colors{text.BgWhite, text.FgBlack},
|
||||
}
|
||||
|
||||
// ColorOptionsBlackOnRedWhite renders Black text on Red/White background.
|
||||
ColorOptionsBlackOnRedWhite = ColorOptions{
|
||||
Footer: text.Colors{text.BgRed, text.FgBlack},
|
||||
Header: text.Colors{text.BgHiRed, text.FgBlack},
|
||||
IndexColumn: text.Colors{text.BgHiRed, text.FgBlack},
|
||||
Row: text.Colors{text.BgHiWhite, text.FgBlack},
|
||||
RowAlternate: text.Colors{text.BgWhite, text.FgBlack},
|
||||
}
|
||||
|
||||
// ColorOptionsBlackOnYellowWhite renders Black text on Yellow/White
|
||||
// background.
|
||||
ColorOptionsBlackOnYellowWhite = ColorOptions{
|
||||
Footer: text.Colors{text.BgYellow, text.FgBlack},
|
||||
Header: text.Colors{text.BgHiYellow, text.FgBlack},
|
||||
IndexColumn: text.Colors{text.BgHiYellow, text.FgBlack},
|
||||
Row: text.Colors{text.BgHiWhite, text.FgBlack},
|
||||
RowAlternate: text.Colors{text.BgWhite, text.FgBlack},
|
||||
}
|
||||
|
||||
// ColorOptionsBlueWhiteOnBlack renders Blue/White text on Black background.
|
||||
ColorOptionsBlueWhiteOnBlack = ColorOptions{
|
||||
Footer: text.Colors{text.FgBlue, text.BgHiBlack},
|
||||
Header: text.Colors{text.FgHiBlue, text.BgHiBlack},
|
||||
IndexColumn: text.Colors{text.FgHiBlue, text.BgHiBlack},
|
||||
Row: text.Colors{text.FgHiWhite, text.BgBlack},
|
||||
RowAlternate: text.Colors{text.FgWhite, text.BgBlack},
|
||||
}
|
||||
|
||||
// ColorOptionsCyanWhiteOnBlack renders Cyan/White text on Black background.
|
||||
ColorOptionsCyanWhiteOnBlack = ColorOptions{
|
||||
Footer: text.Colors{text.FgCyan, text.BgHiBlack},
|
||||
Header: text.Colors{text.FgHiCyan, text.BgHiBlack},
|
||||
IndexColumn: text.Colors{text.FgHiCyan, text.BgHiBlack},
|
||||
Row: text.Colors{text.FgHiWhite, text.BgBlack},
|
||||
RowAlternate: text.Colors{text.FgWhite, text.BgBlack},
|
||||
}
|
||||
|
||||
// ColorOptionsGreenWhiteOnBlack renders Green/White text on Black
|
||||
// background.
|
||||
ColorOptionsGreenWhiteOnBlack = ColorOptions{
|
||||
Footer: text.Colors{text.FgGreen, text.BgHiBlack},
|
||||
Header: text.Colors{text.FgHiGreen, text.BgHiBlack},
|
||||
IndexColumn: text.Colors{text.FgHiGreen, text.BgHiBlack},
|
||||
Row: text.Colors{text.FgHiWhite, text.BgBlack},
|
||||
RowAlternate: text.Colors{text.FgWhite, text.BgBlack},
|
||||
}
|
||||
|
||||
// ColorOptionsMagentaWhiteOnBlack renders Magenta/White text on Black
|
||||
// background.
|
||||
ColorOptionsMagentaWhiteOnBlack = ColorOptions{
|
||||
Footer: text.Colors{text.FgMagenta, text.BgHiBlack},
|
||||
Header: text.Colors{text.FgHiMagenta, text.BgHiBlack},
|
||||
IndexColumn: text.Colors{text.FgHiMagenta, text.BgHiBlack},
|
||||
Row: text.Colors{text.FgHiWhite, text.BgBlack},
|
||||
RowAlternate: text.Colors{text.FgWhite, text.BgBlack},
|
||||
}
|
||||
|
||||
// ColorOptionsRedWhiteOnBlack renders Red/White text on Black background.
|
||||
ColorOptionsRedWhiteOnBlack = ColorOptions{
|
||||
Footer: text.Colors{text.FgRed, text.BgHiBlack},
|
||||
Header: text.Colors{text.FgHiRed, text.BgHiBlack},
|
||||
IndexColumn: text.Colors{text.FgHiRed, text.BgHiBlack},
|
||||
Row: text.Colors{text.FgHiWhite, text.BgBlack},
|
||||
RowAlternate: text.Colors{text.FgWhite, text.BgBlack},
|
||||
}
|
||||
|
||||
// ColorOptionsYellowWhiteOnBlack renders Yellow/White text on Black
|
||||
// background.
|
||||
ColorOptionsYellowWhiteOnBlack = ColorOptions{
|
||||
Footer: text.Colors{text.FgYellow, text.BgHiBlack},
|
||||
Header: text.Colors{text.FgHiYellow, text.BgHiBlack},
|
||||
IndexColumn: text.Colors{text.FgHiYellow, text.BgHiBlack},
|
||||
Row: text.Colors{text.FgHiWhite, text.BgBlack},
|
||||
RowAlternate: text.Colors{text.FgWhite, text.BgBlack},
|
||||
}
|
||||
)
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package table
|
||||
|
||||
import "github.com/jedib0t/go-pretty/v6/text"
|
||||
|
||||
// FormatOptions defines the text-formatting to perform on parts of the Table.
|
||||
type FormatOptions struct {
|
||||
Direction text.Direction // (forced) BiDi direction for each Column
|
||||
Footer text.Format // default text format
|
||||
FooterAlign text.Align // default horizontal align
|
||||
FooterVAlign text.VAlign // default vertical align
|
||||
Header text.Format // default text format
|
||||
HeaderAlign text.Align // default horizontal align
|
||||
HeaderVAlign text.VAlign // default vertical align
|
||||
Row text.Format // default text format
|
||||
RowAlign text.Align // default horizontal align
|
||||
RowVAlign text.VAlign // default vertical align
|
||||
}
|
||||
|
||||
var (
|
||||
// FormatOptionsDefault defines sensible formatting options.
|
||||
FormatOptionsDefault = FormatOptions{
|
||||
Footer: text.FormatUpper,
|
||||
FooterAlign: text.AlignDefault,
|
||||
FooterVAlign: text.VAlignDefault,
|
||||
Header: text.FormatUpper,
|
||||
HeaderAlign: text.AlignDefault,
|
||||
HeaderVAlign: text.VAlignDefault,
|
||||
Row: text.FormatDefault,
|
||||
RowAlign: text.AlignDefault,
|
||||
RowVAlign: text.VAlignDefault,
|
||||
}
|
||||
)
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package table
|
||||
|
||||
// HTMLOptions defines the global options to control HTML rendering.
|
||||
type HTMLOptions struct {
|
||||
ConvertColorsToSpans bool // convert ANSI escape sequences to HTML <span> tags with CSS classes? EscapeText will be true if this is true.
|
||||
CSSClass string // CSS class to set on the overall <table> tag
|
||||
EmptyColumn string // string to replace "" columns with (entire content being "")
|
||||
EscapeText bool // escape text into HTML-safe content?
|
||||
Newline string // string to replace "\n" characters with
|
||||
}
|
||||
|
||||
var (
|
||||
// DefaultHTMLOptions defines sensible HTML rendering defaults.
|
||||
DefaultHTMLOptions = HTMLOptions{
|
||||
ConvertColorsToSpans: true,
|
||||
CSSClass: DefaultHTMLCSSClass,
|
||||
EmptyColumn: " ",
|
||||
EscapeText: true,
|
||||
Newline: "<br/>",
|
||||
}
|
||||
)
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package table
|
||||
|
||||
// Options defines the global options that determine how the Table is
|
||||
// rendered.
|
||||
type Options struct {
|
||||
// DoNotColorBordersAndSeparators disables coloring all the borders and row
|
||||
// or column separators.
|
||||
DoNotColorBordersAndSeparators bool
|
||||
|
||||
// DoNotRenderSeparatorWhenEmpty disables rendering the separator row after
|
||||
// headers when there are no data rows (for example when only headers and/or
|
||||
// footers are present).
|
||||
DoNotRenderSeparatorWhenEmpty bool
|
||||
|
||||
// DrawBorder enables or disables drawing the border around the Table.
|
||||
// Example of a table where it is disabled:
|
||||
// # │ FIRST NAME │ LAST NAME │ SALARY │
|
||||
// ─────┼────────────┼───────────┼────────┼─────────────────────────────
|
||||
// 1 │ Arya │ Stark │ 3000 │
|
||||
// 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow!
|
||||
// 300 │ Tyrion │ Lannister │ 5000 │
|
||||
// ─────┼────────────┼───────────┼────────┼─────────────────────────────
|
||||
// │ │ TOTAL │ 10000 │
|
||||
DrawBorder bool
|
||||
|
||||
// SeparateColumns enables or disable drawing border between columns.
|
||||
// Example of a table where it is disabled:
|
||||
// ┌─────────────────────────────────────────────────────────────────┐
|
||||
// │ # FIRST NAME LAST NAME SALARY │
|
||||
// ├─────────────────────────────────────────────────────────────────┤
|
||||
// │ 1 Arya Stark 3000 │
|
||||
// │ 20 Jon Snow 2000 You know nothing, Jon Snow! │
|
||||
// │ 300 Tyrion Lannister 5000 │
|
||||
// │ TOTAL 10000 │
|
||||
// └─────────────────────────────────────────────────────────────────┘
|
||||
SeparateColumns bool
|
||||
|
||||
// SeparateFooter enables or disable drawing border between the footer and
|
||||
// the rows. Example of a table where it is disabled:
|
||||
// ┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐
|
||||
// │ # │ FIRST NAME │ LAST NAME │ SALARY │ │
|
||||
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
|
||||
// │ 1 │ Arya │ Stark │ 3000 │ │
|
||||
// │ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │
|
||||
// │ 300 │ Tyrion │ Lannister │ 5000 │ │
|
||||
// │ │ │ TOTAL │ 10000 │ │
|
||||
// └─────┴────────────┴───────────┴────────┴─────────────────────────────┘
|
||||
SeparateFooter bool
|
||||
|
||||
// SeparateHeader enables or disable drawing border between the header and
|
||||
// the rows. Example of a table where it is disabled:
|
||||
// ┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐
|
||||
// │ # │ FIRST NAME │ LAST NAME │ SALARY │ │
|
||||
// │ 1 │ Arya │ Stark │ 3000 │ │
|
||||
// │ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │
|
||||
// │ 300 │ Tyrion │ Lannister │ 5000 │ │
|
||||
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
|
||||
// │ │ │ TOTAL │ 10000 │ │
|
||||
// └─────┴────────────┴───────────┴────────┴─────────────────────────────┘
|
||||
SeparateHeader bool
|
||||
|
||||
// SeparateRows enables or disables drawing separators between each row.
|
||||
// Example of a table where it is enabled:
|
||||
// ┌─────┬────────────┬───────────┬────────┬─────────────────────────────┐
|
||||
// │ # │ FIRST NAME │ LAST NAME │ SALARY │ │
|
||||
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
|
||||
// │ 1 │ Arya │ Stark │ 3000 │ │
|
||||
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
|
||||
// │ 20 │ Jon │ Snow │ 2000 │ You know nothing, Jon Snow! │
|
||||
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
|
||||
// │ 300 │ Tyrion │ Lannister │ 5000 │ │
|
||||
// ├─────┼────────────┼───────────┼────────┼─────────────────────────────┤
|
||||
// │ │ │ TOTAL │ 10000 │ │
|
||||
// └─────┴────────────┴───────────┴────────┴─────────────────────────────┘
|
||||
SeparateRows bool
|
||||
}
|
||||
|
||||
var (
|
||||
// OptionsDefault defines sensible global options.
|
||||
OptionsDefault = Options{
|
||||
DoNotColorBordersAndSeparators: false,
|
||||
DrawBorder: true,
|
||||
SeparateColumns: true,
|
||||
SeparateFooter: true,
|
||||
SeparateHeader: true,
|
||||
SeparateRows: false,
|
||||
}
|
||||
|
||||
// OptionsNoBorders sets up a table without any borders.
|
||||
OptionsNoBorders = Options{
|
||||
DoNotColorBordersAndSeparators: false,
|
||||
DrawBorder: false,
|
||||
SeparateColumns: true,
|
||||
SeparateFooter: true,
|
||||
SeparateHeader: true,
|
||||
SeparateRows: false,
|
||||
}
|
||||
|
||||
// OptionsNoBordersAndSeparators sets up a table without any borders or
|
||||
// separators.
|
||||
OptionsNoBordersAndSeparators = Options{
|
||||
DoNotColorBordersAndSeparators: false,
|
||||
DrawBorder: false,
|
||||
SeparateColumns: false,
|
||||
SeparateFooter: false,
|
||||
SeparateHeader: false,
|
||||
SeparateRows: false,
|
||||
}
|
||||
)
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package table
|
||||
|
||||
// SizeOptions defines the way to control the width of the table output.
|
||||
type SizeOptions struct {
|
||||
// WidthMax is the maximum allotted width for the full row;
|
||||
// any content beyond this will be truncated using the text
|
||||
// in Style.Box.UnfinishedRow
|
||||
WidthMax int
|
||||
// WidthMin is the minimum allotted width for the full row;
|
||||
// columns will be auto-expanded until the overall width
|
||||
// is met
|
||||
WidthMin int
|
||||
}
|
||||
|
||||
var (
|
||||
// SizeOptionsDefault defines sensible size options - basically NONE.
|
||||
SizeOptionsDefault = SizeOptions{
|
||||
WidthMax: 0,
|
||||
WidthMin: 0,
|
||||
}
|
||||
)
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package table
|
||||
|
||||
import "github.com/jedib0t/go-pretty/v6/text"
|
||||
|
||||
// TitleOptions defines the way the title text is to be rendered.
|
||||
type TitleOptions struct {
|
||||
Align text.Align
|
||||
Colors text.Colors
|
||||
Format text.Format
|
||||
}
|
||||
|
||||
var (
|
||||
// TitleOptionsDefault defines sensible title options - basically NONE.
|
||||
TitleOptionsDefault = TitleOptions{}
|
||||
|
||||
// TitleOptionsBright renders Bright Bold text on Dark background.
|
||||
TitleOptionsBright = TitleOptionsBlackOnCyan
|
||||
|
||||
// TitleOptionsDark renders Dark Bold text on Bright background.
|
||||
TitleOptionsDark = TitleOptionsCyanOnBlack
|
||||
|
||||
// TitleOptionsBlackOnBlue renders Black text on Blue background.
|
||||
TitleOptionsBlackOnBlue = TitleOptions{
|
||||
Colors: append(ColorOptionsBlackOnBlueWhite.Header, text.Bold),
|
||||
}
|
||||
|
||||
// TitleOptionsBlackOnCyan renders Black Bold text on Cyan background.
|
||||
TitleOptionsBlackOnCyan = TitleOptions{
|
||||
Colors: append(ColorOptionsBlackOnCyanWhite.Header, text.Bold),
|
||||
}
|
||||
|
||||
// TitleOptionsBlackOnGreen renders Black Bold text onGreen background.
|
||||
TitleOptionsBlackOnGreen = TitleOptions{
|
||||
Colors: append(ColorOptionsBlackOnGreenWhite.Header, text.Bold),
|
||||
}
|
||||
|
||||
// TitleOptionsBlackOnMagenta renders Black Bold text on Magenta background.
|
||||
TitleOptionsBlackOnMagenta = TitleOptions{
|
||||
Colors: append(ColorOptionsBlackOnMagentaWhite.Header, text.Bold),
|
||||
}
|
||||
|
||||
// TitleOptionsBlackOnRed renders Black Bold text on Red background.
|
||||
TitleOptionsBlackOnRed = TitleOptions{
|
||||
Colors: append(ColorOptionsBlackOnRedWhite.Header, text.Bold),
|
||||
}
|
||||
|
||||
// TitleOptionsBlackOnYellow renders Black Bold text on Yellow background.
|
||||
TitleOptionsBlackOnYellow = TitleOptions{
|
||||
Colors: append(ColorOptionsBlackOnYellowWhite.Header, text.Bold),
|
||||
}
|
||||
|
||||
// TitleOptionsBlueOnBlack renders Blue Bold text on Black background.
|
||||
TitleOptionsBlueOnBlack = TitleOptions{
|
||||
Colors: append(ColorOptionsBlueWhiteOnBlack.Header, text.Bold),
|
||||
}
|
||||
|
||||
// TitleOptionsCyanOnBlack renders Cyan Bold text on Black background.
|
||||
TitleOptionsCyanOnBlack = TitleOptions{
|
||||
Colors: append(ColorOptionsCyanWhiteOnBlack.Header, text.Bold),
|
||||
}
|
||||
|
||||
// TitleOptionsGreenOnBlack renders Green Bold text on Black background.
|
||||
TitleOptionsGreenOnBlack = TitleOptions{
|
||||
Colors: append(ColorOptionsGreenWhiteOnBlack.Header, text.Bold),
|
||||
}
|
||||
|
||||
// TitleOptionsMagentaOnBlack renders Magenta Bold text on Black background.
|
||||
TitleOptionsMagentaOnBlack = TitleOptions{
|
||||
Colors: append(ColorOptionsMagentaWhiteOnBlack.Header, text.Bold),
|
||||
}
|
||||
|
||||
// TitleOptionsRedOnBlack renders Red Bold text on Black background.
|
||||
TitleOptionsRedOnBlack = TitleOptions{
|
||||
Colors: append(ColorOptionsRedWhiteOnBlack.Header, text.Bold),
|
||||
}
|
||||
|
||||
// TitleOptionsYellowOnBlack renders Yellow Bold text on Black background.
|
||||
TitleOptionsYellowOnBlack = TitleOptions{
|
||||
Colors: append(ColorOptionsYellowWhiteOnBlack.Header, text.Bold),
|
||||
}
|
||||
)
|
||||
+62
-16
@@ -28,6 +28,8 @@ type Table struct {
|
||||
// columnConfigMap stores the custom-configuration by column
|
||||
// number and is generated before rendering
|
||||
columnConfigMap map[int]ColumnConfig
|
||||
// directionModifier caches the direction modifier string to avoid repeated calls
|
||||
directionModifier string
|
||||
// firstRowOfPage tells if the renderer is on the first row of a page?
|
||||
firstRowOfPage bool
|
||||
// htmlCSSClass stores the HTML CSS Class to use on the <table> node
|
||||
@@ -50,6 +52,8 @@ type Table struct {
|
||||
outputMirror io.Writer
|
||||
// pager controls how the output is separated into pages
|
||||
pager pager
|
||||
// renderMode contains the type of table to render
|
||||
renderMode renderMode
|
||||
// rows stores the rows that make up the body (in string form)
|
||||
rows []rowStr
|
||||
// rowsColors stores the text.Colors over-rides for each row as defined by
|
||||
@@ -59,6 +63,8 @@ type Table struct {
|
||||
rowsConfigMap map[int]RowConfig
|
||||
// rowsRaw stores the rows that make up the body
|
||||
rowsRaw []Row
|
||||
// rowsRawFiltered is the filtered version of rowsRaw
|
||||
rowsRawFiltered []Row
|
||||
// rowsFooter stores the rows that make up the footer (in string form)
|
||||
rowsFooter []rowStr
|
||||
// rowsFooterConfigs stores RowConfig for each footer row
|
||||
@@ -76,9 +82,11 @@ type Table struct {
|
||||
rowPainter RowPainter
|
||||
// rowPainterWithAttributes is same as rowPainter, but with attributes
|
||||
rowPainterWithAttributes RowPainterWithAttributes
|
||||
// rowSeparator is a dummy row that contains the separator columns (dashes
|
||||
// that make up the separator between header/body/footer
|
||||
rowSeparator rowStr
|
||||
// rowSeparators contains the separator columns (dashes that make up the
|
||||
// separators between title/header/body/footer
|
||||
rowSeparators map[string]rowStr
|
||||
// rowSeparatorStrings contains the separator strings for each separator type
|
||||
rowSeparatorStrings map[separatorType]string
|
||||
// separators is used to keep track of all rowIndices after which a
|
||||
// separator has to be rendered
|
||||
separators map[int]bool
|
||||
@@ -86,6 +94,8 @@ type Table struct {
|
||||
sortBy []SortBy
|
||||
// sortedRowIndices is the output of sorting
|
||||
sortedRowIndices []int
|
||||
// filterBy stores the filter criteria
|
||||
filterBy []FilterBy
|
||||
// style contains all the strings used to draw the table, and more
|
||||
style *Style
|
||||
// suppressEmptyColumns hides columns which have no content on all regular
|
||||
@@ -127,12 +137,16 @@ func (t *Table) AppendHeader(row Row, config ...RowConfig) {
|
||||
//
|
||||
// Only the first item in the "config" will be tagged against this row.
|
||||
func (t *Table) AppendRow(row Row, config ...RowConfig) {
|
||||
t.rowsRaw = append(t.rowsRaw, row)
|
||||
t.rowsRawFiltered = append(t.rowsRawFiltered, row)
|
||||
// Keep original rows in sync for filtering
|
||||
rowCopy := make(Row, len(row))
|
||||
copy(rowCopy, row)
|
||||
t.rowsRaw = append(t.rowsRaw, rowCopy)
|
||||
if len(config) > 0 {
|
||||
if t.rowsConfigMap == nil {
|
||||
t.rowsConfigMap = make(map[int]RowConfig)
|
||||
}
|
||||
t.rowsConfigMap[len(t.rowsRaw)-1] = config[0]
|
||||
t.rowsConfigMap[len(t.rowsRawFiltered)-1] = config[0]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,11 +178,17 @@ func (t *Table) AppendSeparator() {
|
||||
if t.separators == nil {
|
||||
t.separators = make(map[int]bool)
|
||||
}
|
||||
if len(t.rowsRaw) > 0 {
|
||||
t.separators[len(t.rowsRaw)-1] = true
|
||||
if len(t.rowsRawFiltered) > 0 {
|
||||
t.separators[len(t.rowsRawFiltered)-1] = true
|
||||
}
|
||||
}
|
||||
|
||||
// FilterBy sets the rules for filtering the Rows. All filters are applied with
|
||||
// AND logic (all must match). Filters are applied before sorting.
|
||||
func (t *Table) FilterBy(filterBy []FilterBy) {
|
||||
t.filterBy = filterBy
|
||||
}
|
||||
|
||||
// ImportGrid helps import 1d or 2d arrays as rows.
|
||||
func (t *Table) ImportGrid(grid interface{}) bool {
|
||||
rows := objAsSlice(grid)
|
||||
@@ -190,7 +210,7 @@ func (t *Table) ImportGrid(grid interface{}) bool {
|
||||
|
||||
// Length returns the number of rows to be rendered.
|
||||
func (t *Table) Length() int {
|
||||
return len(t.rowsRaw)
|
||||
return len(t.rowsRawFiltered)
|
||||
}
|
||||
|
||||
// Pager returns an object that splits the table output into pages and
|
||||
@@ -231,6 +251,7 @@ func (t *Table) ResetHeaders() {
|
||||
|
||||
// ResetRows resets and clears all the rows appended earlier.
|
||||
func (t *Table) ResetRows() {
|
||||
t.rowsRawFiltered = nil
|
||||
t.rowsRaw = nil
|
||||
t.separators = nil
|
||||
}
|
||||
@@ -305,12 +326,12 @@ func (t *Table) SetRowPainter(painter interface{}) {
|
||||
t.rowPainterWithAttributes = nil
|
||||
|
||||
// if called as SetRowPainter(RowPainter(func...))
|
||||
switch painter.(type) {
|
||||
switch p := painter.(type) {
|
||||
case RowPainter:
|
||||
t.rowPainter = painter.(RowPainter)
|
||||
t.rowPainter = p
|
||||
return
|
||||
case RowPainterWithAttributes:
|
||||
t.rowPainterWithAttributes = painter.(RowPainterWithAttributes)
|
||||
t.rowPainterWithAttributes = p
|
||||
return
|
||||
}
|
||||
|
||||
@@ -367,6 +388,31 @@ func (t *Table) SuppressTrailingSpaces() {
|
||||
t.suppressTrailingSpaces = true
|
||||
}
|
||||
|
||||
// calculateNumColumnsFromRaw calculates the number of columns from raw rows and headers
|
||||
func (t *Table) calculateNumColumnsFromRaw() {
|
||||
t.numColumns = 0
|
||||
// Check headers first
|
||||
if len(t.rowsHeaderRaw) > 0 {
|
||||
for _, headerRow := range t.rowsHeaderRaw {
|
||||
if len(headerRow) > t.numColumns {
|
||||
t.numColumns = len(headerRow)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Check data rows
|
||||
for _, row := range t.rowsRawFiltered {
|
||||
if len(row) > t.numColumns {
|
||||
t.numColumns = len(row)
|
||||
}
|
||||
}
|
||||
// Check footer rows
|
||||
for _, footerRow := range t.rowsFooterRaw {
|
||||
if len(footerRow) > t.numColumns {
|
||||
t.numColumns = len(footerRow)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Table) getAlign(colIdx int, hint renderHint) text.Align {
|
||||
align := text.AlignDefault
|
||||
if cfg, ok := t.columnConfigMap[colIdx]; ok {
|
||||
@@ -505,13 +551,13 @@ func (t *Table) getColumnSeparator(row rowStr, colIdx int, hint renderHint) stri
|
||||
if hint.isSeparatorRow {
|
||||
if hint.isBorderTop {
|
||||
if t.shouldMergeCellsHorizontallyBelow(row, colIdx, hint) {
|
||||
separator = t.style.Box.MiddleHorizontal
|
||||
separator = t.style.Box.middleHorizontal(hint.separatorType)
|
||||
} else {
|
||||
separator = t.style.Box.TopSeparator
|
||||
}
|
||||
} else if hint.isBorderBottom {
|
||||
if t.shouldMergeCellsHorizontallyAbove(row, colIdx, hint) {
|
||||
separator = t.style.Box.MiddleHorizontal
|
||||
separator = t.style.Box.middleHorizontal(hint.separatorType)
|
||||
} else {
|
||||
separator = t.style.Box.BottomSeparator
|
||||
}
|
||||
@@ -531,7 +577,7 @@ func (t *Table) getColumnSeparatorNonBorder(mergeCellsAbove bool, mergeCellsBelo
|
||||
}
|
||||
|
||||
mergeCurrCol := t.shouldMergeCellsVerticallyAbove(colIdx-1, hint)
|
||||
return t.getColumnSeparatorNonBorderNonAutoIndex(mergeCellsAbove, mergeCellsBelow, mergeCurrCol, mergeNextCol)
|
||||
return t.getColumnSeparatorNonBorderNonAutoIndex(mergeCellsAbove, mergeCellsBelow, mergeCurrCol, mergeNextCol, hint)
|
||||
}
|
||||
|
||||
func (t *Table) getColumnSeparatorNonBorderAutoIndex(mergeNextCol bool, hint renderHint) string {
|
||||
@@ -546,11 +592,11 @@ func (t *Table) getColumnSeparatorNonBorderAutoIndex(mergeNextCol bool, hint ren
|
||||
return t.style.Box.MiddleSeparator
|
||||
}
|
||||
|
||||
func (t *Table) getColumnSeparatorNonBorderNonAutoIndex(mergeCellsAbove bool, mergeCellsBelow bool, mergeCurrCol bool, mergeNextCol bool) string {
|
||||
func (t *Table) getColumnSeparatorNonBorderNonAutoIndex(mergeCellsAbove bool, mergeCellsBelow bool, mergeCurrCol bool, mergeNextCol bool, hint renderHint) string {
|
||||
if mergeCellsAbove && mergeCellsBelow && mergeCurrCol && mergeNextCol {
|
||||
return t.style.Box.EmptySeparator
|
||||
} else if mergeCellsAbove && mergeCellsBelow {
|
||||
return t.style.Box.MiddleHorizontal
|
||||
return t.style.Box.middleHorizontal(hint.separatorType)
|
||||
} else if mergeCellsAbove {
|
||||
return t.style.Box.TopSeparator
|
||||
} else if mergeCellsBelow {
|
||||
|
||||
+44
@@ -1,8 +1,10 @@
|
||||
package table
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// AutoIndexColumnID returns a unique Column ID/Name for the given Column Number.
|
||||
@@ -26,6 +28,48 @@ func widthEnforcerNone(col string, _ int) string {
|
||||
return col
|
||||
}
|
||||
|
||||
// convertValueToString converts a value to string using fast type assertions
|
||||
// for common numeric types before falling back to fmt.Sprint.
|
||||
//
|
||||
//gocyclo:ignore
|
||||
func convertValueToString(v interface{}) string {
|
||||
switch val := v.(type) {
|
||||
case int:
|
||||
return strconv.FormatInt(int64(val), 10)
|
||||
case int8:
|
||||
return strconv.FormatInt(int64(val), 10)
|
||||
case int16:
|
||||
return strconv.FormatInt(int64(val), 10)
|
||||
case int32:
|
||||
return strconv.FormatInt(int64(val), 10)
|
||||
case int64:
|
||||
return strconv.FormatInt(val, 10)
|
||||
case uint:
|
||||
return strconv.FormatUint(uint64(val), 10)
|
||||
case uint8:
|
||||
return strconv.FormatUint(uint64(val), 10)
|
||||
case uint16:
|
||||
return strconv.FormatUint(uint64(val), 10)
|
||||
case uint32:
|
||||
return strconv.FormatUint(uint64(val), 10)
|
||||
case uint64:
|
||||
return strconv.FormatUint(val, 10)
|
||||
case float32:
|
||||
return strconv.FormatFloat(float64(val), 'g', -1, 32)
|
||||
case float64:
|
||||
return strconv.FormatFloat(val, 'g', -1, 64)
|
||||
case bool:
|
||||
if val {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
case string:
|
||||
return val
|
||||
default:
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
}
|
||||
|
||||
// isNumber returns true if the argument is a numeric type; false otherwise.
|
||||
func isNumber(x interface{}) bool {
|
||||
if x == nil {
|
||||
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
package table
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/jedib0t/go-pretty/v6/text"
|
||||
)
|
||||
|
||||
// convertEscSequencesToSpans converts ANSI escape sequences to HTML <span> tags with CSS classes.
|
||||
func convertEscSequencesToSpans(str string) string {
|
||||
converter := newEscSeqToSpanConverter()
|
||||
return converter.Convert(str)
|
||||
}
|
||||
|
||||
// escSeqToSpanConverter converts ANSI escape sequences to HTML <span> tags with CSS classes.
|
||||
type escSeqToSpanConverter struct {
|
||||
result strings.Builder
|
||||
esp text.EscSeqParser
|
||||
currentColors map[int]bool
|
||||
}
|
||||
|
||||
// newEscSeqToSpanConverter creates a new escape sequence to span converter.
|
||||
func newEscSeqToSpanConverter() *escSeqToSpanConverter {
|
||||
return &escSeqToSpanConverter{
|
||||
currentColors: make(map[int]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// Convert converts ANSI escape sequences in the string to HTML <span> tags with CSS classes.
|
||||
func (c *escSeqToSpanConverter) Convert(str string) string {
|
||||
c.reset()
|
||||
|
||||
// Process the string character by character
|
||||
for _, char := range str {
|
||||
wasInSequence := c.esp.InSequence()
|
||||
c.esp.Consume(char)
|
||||
|
||||
if c.esp.InSequence() {
|
||||
// We're inside an escape sequence, skip it (don't write to result)
|
||||
continue
|
||||
}
|
||||
|
||||
if wasInSequence {
|
||||
// We just finished an escape sequence, update colors
|
||||
newColors := make(map[int]bool)
|
||||
for _, code := range c.esp.Codes() {
|
||||
newColors[code] = true
|
||||
}
|
||||
c.updateSpan(newColors)
|
||||
} else {
|
||||
// Regular character, escape it for HTML safety and write it
|
||||
// (will be inside current span if colors are active)
|
||||
c.writeEscapedRune(char)
|
||||
}
|
||||
}
|
||||
|
||||
// Close any open span
|
||||
if len(c.currentColors) > 0 {
|
||||
c.result.WriteString("</span>")
|
||||
}
|
||||
|
||||
return c.result.String()
|
||||
}
|
||||
|
||||
// clearColors clears the current color tracking.
|
||||
func (c *escSeqToSpanConverter) clearColors() {
|
||||
c.currentColors = make(map[int]bool)
|
||||
}
|
||||
|
||||
// closeSpan closes the current span if one is open.
|
||||
func (c *escSeqToSpanConverter) closeSpan() {
|
||||
if len(c.currentColors) > 0 {
|
||||
c.result.WriteString("</span>")
|
||||
}
|
||||
}
|
||||
|
||||
// colorsChanged checks if the color set has changed.
|
||||
func (c *escSeqToSpanConverter) colorsChanged(newColors map[int]bool) bool {
|
||||
// we never set the map values to false, so a simple size compare is enough
|
||||
return len(c.currentColors) != len(newColors)
|
||||
}
|
||||
|
||||
// cssClasses converts color codes to CSS class names.
|
||||
func (c *escSeqToSpanConverter) cssClasses(codes map[int]bool) string {
|
||||
var colors text.Colors
|
||||
for code := range codes {
|
||||
colors = append(colors, text.Color(code))
|
||||
}
|
||||
return colors.CSSClasses()
|
||||
}
|
||||
|
||||
// openSpan opens a new span with the given CSS class and tracks the colors.
|
||||
func (c *escSeqToSpanConverter) openSpan(class string, newColors map[int]bool) {
|
||||
c.result.WriteString("<span class=\"")
|
||||
c.result.WriteString(class)
|
||||
c.result.WriteString("\">")
|
||||
// Track colors since we opened a span
|
||||
c.currentColors = make(map[int]bool)
|
||||
for code := range newColors {
|
||||
c.currentColors[code] = true
|
||||
}
|
||||
}
|
||||
|
||||
// reset initializes the converter state for a new conversion.
|
||||
func (c *escSeqToSpanConverter) reset() {
|
||||
c.result.Reset()
|
||||
c.esp = text.EscSeqParser{}
|
||||
c.currentColors = make(map[int]bool)
|
||||
}
|
||||
|
||||
// updateSpan updates span tags when colors change.
|
||||
func (c *escSeqToSpanConverter) updateSpan(newColors map[int]bool) {
|
||||
if !c.colorsChanged(newColors) {
|
||||
return
|
||||
}
|
||||
|
||||
c.closeSpan()
|
||||
|
||||
// Open new span if there are colors with valid CSS classes
|
||||
if len(newColors) > 0 {
|
||||
class := c.cssClasses(newColors)
|
||||
if class != "" {
|
||||
c.openSpan(class, newColors)
|
||||
} else {
|
||||
// No CSS classes, so don't track these colors
|
||||
c.clearColors()
|
||||
}
|
||||
} else {
|
||||
// No colors, clear tracking
|
||||
c.clearColors()
|
||||
}
|
||||
}
|
||||
|
||||
// writeEscapedRune writes a rune to the result, escaping it if necessary for HTML safety.
|
||||
func (c *escSeqToSpanConverter) writeEscapedRune(char rune) {
|
||||
switch char {
|
||||
case '<':
|
||||
c.result.WriteString("<")
|
||||
case '>':
|
||||
c.result.WriteString(">")
|
||||
case '&':
|
||||
c.result.WriteString("&")
|
||||
case '"':
|
||||
c.result.WriteString(""")
|
||||
case '\'':
|
||||
c.result.WriteString("'")
|
||||
default:
|
||||
// Most characters don't need escaping, write directly
|
||||
c.result.WriteRune(char)
|
||||
}
|
||||
}
|
||||
+1
@@ -11,6 +11,7 @@ type Writer interface {
|
||||
AppendRow(row Row, configs ...RowConfig)
|
||||
AppendRows(rows []Row, configs ...RowConfig)
|
||||
AppendSeparator()
|
||||
FilterBy(filterBy []FilterBy)
|
||||
ImportGrid(grid interface{}) bool
|
||||
Length() int
|
||||
Pager(opts ...PagerOption) Pager
|
||||
|
||||
+138
-5
@@ -1,8 +1,141 @@
|
||||
# text
|
||||
# Text
|
||||
[](https://pkg.go.dev/github.com/jedib0t/go-pretty/v6/text)
|
||||
|
||||
[](https://pkg.go.dev/github.com/jedib0t/go-pretty/v6/text)
|
||||
|
||||
Package with utility functions to manipulate strings/text.
|
||||
Package with utility functions to manipulate strings/text with full support for
|
||||
ANSI escape sequences (colors, formatting, etc.).
|
||||
|
||||
Used heavily in the other packages in this repo ([list](../list),
|
||||
[progress](../progress), and [table](../table)).
|
||||
[progress](../progress), and [table](../table)).
|
||||
|
||||
## Features
|
||||
|
||||
### Colors & Formatting
|
||||
|
||||
- **ANSI Color Support** - Full support for terminal colors and formatting
|
||||
- Foreground colors (Black, Red, Green, Yellow, Blue, Magenta, Cyan, White)
|
||||
- Background colors (matching foreground set)
|
||||
- Hi-intensity variants for both foreground and background
|
||||
- **256-color palette support** - Extended color support for terminals
|
||||
- Standard 16 colors (0-15)
|
||||
- RGB cube colors (16-231) - 216 colors organized in a 6x6x6 cube
|
||||
- Grayscale colors (232-255) - 24 shades of gray
|
||||
- Helper functions: `Fg256Color(index)`, `Bg256Color(index)`, `Fg256RGB(r, g, b)`, `Bg256RGB(r, g, b)`
|
||||
- Text attributes (Bold, Faint, Italic, Underline, Blink, Reverse, Concealed, CrossedOut)
|
||||
- Automatic color detection based on environment variables (`NO_COLOR`, `FORCE_COLOR`, `TERM`)
|
||||
- Global enable/disable functions for colors
|
||||
- Cached escape sequences for performance
|
||||
- **Text Formatting** - Transform text while preserving escape sequences
|
||||
- `FormatDefault` - No transformation
|
||||
- `FormatLower` - Convert to lowercase
|
||||
- `FormatTitle` - Convert to title case
|
||||
- `FormatUpper` - Convert to uppercase
|
||||
- **HTML Support** - Generate HTML class attributes for colors
|
||||
- **Color Combinations** - Combine multiple colors and attributes
|
||||
|
||||
### Alignment
|
||||
|
||||
- **Horizontal Alignment**
|
||||
- `AlignDefault` / `AlignLeft` - Left-align text
|
||||
- `AlignCenter` - Center-align text
|
||||
- `AlignRight` - Right-align text
|
||||
- `AlignJustify` - Justify text (distribute spaces between words)
|
||||
- `AlignAuto` - Auto-detect: right-align numbers, left-align text
|
||||
- HTML and Markdown property generation for alignment
|
||||
- **Vertical Alignment**
|
||||
- `VAlignTop` - Align to top
|
||||
- `VAlignMiddle` - Align to middle
|
||||
- `VAlignBottom` - Align to bottom
|
||||
- Works with both string arrays and multi-line strings
|
||||
- HTML property generation for vertical alignment
|
||||
|
||||
### Text Wrapping
|
||||
|
||||
- **WrapHard** - Hard wrap at specified length, breaks words if needed
|
||||
- Handles ANSI escape sequences without breaking formatting
|
||||
- Preserves paragraph breaks
|
||||
- **WrapSoft** - Soft wrap at specified length, tries to keep words intact
|
||||
- Handles ANSI escape sequences without breaking formatting
|
||||
- Preserves paragraph breaks
|
||||
- **WrapText** - Similar to WrapHard but also respects line breaks
|
||||
- Handles ANSI escape sequences without breaking formatting
|
||||
|
||||
### String Utilities
|
||||
|
||||
- **Width Calculation**
|
||||
- `StringWidth` - Calculate display width of string (including escape sequences)
|
||||
- `StringWidthWithoutEscSequences` - Calculate display width ignoring escape sequences
|
||||
- `RuneWidth` - Calculate display width of a single rune (handles East Asian characters)
|
||||
- `LongestLineLen` - Find the longest line in a multi-line string
|
||||
- **String Manipulation**
|
||||
- `Trim` - Trim string to specified length while preserving escape sequences
|
||||
- `Pad` - Pad string to specified length with a character
|
||||
- `Snip` - Snip string to specified length with an indicator (e.g., "~")
|
||||
- `RepeatAndTrim` - Repeat string until it reaches specified length
|
||||
- `InsertEveryN` - Insert a character every N characters
|
||||
- `ProcessCRLF` - Process carriage returns and line feeds correctly
|
||||
- `Widen` - Convert half-width characters to full-width
|
||||
- **Escape Sequence Handling**
|
||||
- All functions properly handle ANSI escape sequences
|
||||
- Escape sequences are preserved during transformations
|
||||
- Width calculations ignore escape sequences
|
||||
- `EscSeqParser` - Parser for advanced escape sequence parsing and tracking
|
||||
- Supports both CSI (Control Sequence Introducer) and OSI (Operating System Command) formats
|
||||
- Tracks active formatting codes and can generate consolidated escape sequences
|
||||
- Full support for 256-color escape sequences (`\x1b[38;5;n`m` and `\x1b[48;5;n`m`)
|
||||
|
||||
### Cursor Control
|
||||
|
||||
- Move cursor in all directions
|
||||
- `CursorUp` - Move cursor up N lines
|
||||
- `CursorDown` - Move cursor down N lines
|
||||
- `CursorLeft` - Move cursor left N characters
|
||||
- `CursorRight` - Move cursor right N characters
|
||||
- `EraseLine` - Erase all characters to the right of cursor
|
||||
- Generate ANSI escape sequences for terminal cursor manipulation
|
||||
|
||||
### Hyperlinks
|
||||
|
||||
- **Terminal Hyperlinks** - Create clickable hyperlinks in supported terminals
|
||||
- Uses OSC 8 escape sequences
|
||||
- Format: `Hyperlink(url, text)`
|
||||
- Falls back to plain text in unsupported terminals
|
||||
|
||||
### Transformers
|
||||
|
||||
- **Number Transformer** - Format numbers with colors
|
||||
- Positive numbers colored green
|
||||
- Negative numbers colored red
|
||||
- Custom format string support (e.g., `%.2f`)
|
||||
- Supports all numeric types (int, uint, float)
|
||||
- **JSON Transformer** - Pretty-print JSON strings or objects
|
||||
- Customizable indentation (prefix and indent string)
|
||||
- Validates JSON before formatting
|
||||
- **Time Transformer** - Format time.Time objects
|
||||
- Custom layout support (e.g., `time.RFC3339`)
|
||||
- Timezone localization support
|
||||
- Auto-detects common time formats from strings
|
||||
- **Unix Time Transformer** - Format Unix timestamps
|
||||
- Handles seconds, milliseconds, microseconds, and nanoseconds
|
||||
- Auto-detects timestamp unit based on value
|
||||
- Timezone localization support
|
||||
- **URL Transformer** - Format URLs with styling
|
||||
- Underlined and colored blue by default
|
||||
- Custom color support
|
||||
|
||||
### Text Direction
|
||||
|
||||
- **Bidirectional Text Support**
|
||||
- `LeftToRight` - Force left-to-right text direction
|
||||
- `RightToLeft` - Force right-to-left text direction
|
||||
- Uses Unicode directional markers
|
||||
|
||||
### Filtering
|
||||
|
||||
- **String Filtering** - Filter string slices with custom functions
|
||||
- `Filter(slice, predicate)` - Returns filtered slice
|
||||
|
||||
### East Asian Character Support
|
||||
|
||||
- Proper width calculation for East Asian characters (full-width, half-width)
|
||||
- Configurable East Asian width handling via `OverrideRuneWidthEastAsianWidth()`
|
||||
- Handles mixed character sets correctly
|
||||
+12
-8
@@ -15,18 +15,22 @@ var ANSICodesSupported = areANSICodesSupported()
|
||||
// Escape("Nymeria\x1b[94mGhost\x1b[0mLady", "\x1b[91m") == "\x1b[91mNymeria\x1b[94mGhost\x1b[0m\x1b[91mLady\x1b[0m"
|
||||
// Escape("Nymeria \x1b[94mGhost\x1b[0m Lady", "\x1b[91m") == "\x1b[91mNymeria \x1b[94mGhost\x1b[0m\x1b[91m Lady\x1b[0m"
|
||||
func Escape(str string, escapeSeq string) string {
|
||||
out := ""
|
||||
var out strings.Builder
|
||||
// Estimate capacity: original string + escape sequences
|
||||
out.Grow(len(str) + len(escapeSeq)*3 + len(EscapeReset)*2)
|
||||
|
||||
if !strings.HasPrefix(str, EscapeStart) {
|
||||
out += escapeSeq
|
||||
out.WriteString(escapeSeq)
|
||||
}
|
||||
out += strings.Replace(str, EscapeReset, EscapeReset+escapeSeq, -1)
|
||||
if !strings.HasSuffix(out, EscapeReset) {
|
||||
out += EscapeReset
|
||||
out.WriteString(strings.ReplaceAll(str, EscapeReset, EscapeReset+escapeSeq))
|
||||
if !strings.HasSuffix(out.String(), EscapeReset) {
|
||||
out.WriteString(EscapeReset)
|
||||
}
|
||||
if strings.Contains(out, escapeSeq+EscapeReset) {
|
||||
out = strings.Replace(out, escapeSeq+EscapeReset, "", -1)
|
||||
result := out.String()
|
||||
if strings.Contains(result, escapeSeq+EscapeReset) {
|
||||
result = strings.ReplaceAll(result, escapeSeq+EscapeReset, "")
|
||||
}
|
||||
return out
|
||||
return result
|
||||
}
|
||||
|
||||
// StripEscape strips all ANSI Escape Sequence from the string.
|
||||
|
||||
+7
-1
@@ -3,6 +3,12 @@
|
||||
|
||||
package text
|
||||
|
||||
import "os"
|
||||
|
||||
func areANSICodesSupported() bool {
|
||||
return true
|
||||
// On Unix systems, ANSI codes are generally supported unless TERM is "dumb"
|
||||
// This is a basic check; 256-color sequences are ANSI sequences and will
|
||||
// be handled by terminals that support them (or ignored by those that don't)
|
||||
term := os.Getenv("TERM")
|
||||
return term != "dumb"
|
||||
}
|
||||
+183
-23
@@ -9,6 +9,7 @@ import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// colorsEnabled is true if colors are enabled and supported by the terminal.
|
||||
var colorsEnabled = areColorsOnInTheEnv() && areANSICodesSupported()
|
||||
|
||||
// DisableColors (forcefully) disables color coding globally.
|
||||
@@ -21,13 +22,24 @@ func EnableColors() {
|
||||
colorsEnabled = true
|
||||
}
|
||||
|
||||
// areColorsOnInTheEnv returns true is colors are not disable using
|
||||
// areColorsOnInTheEnv returns true if colors are not disabled using
|
||||
// well known environment variables.
|
||||
func areColorsOnInTheEnv() bool {
|
||||
if os.Getenv("FORCE_COLOR") == "1" {
|
||||
// FORCE_COLOR takes precedence - if set to a truthy value, enable colors
|
||||
forceColor := os.Getenv("FORCE_COLOR")
|
||||
if forceColor != "" && forceColor != "0" && forceColor != "false" {
|
||||
return true
|
||||
}
|
||||
return os.Getenv("NO_COLOR") == "" || os.Getenv("NO_COLOR") == "0"
|
||||
|
||||
// NO_COLOR: if set to any non-empty value (except "0"), disable colors
|
||||
// Note: "0" is treated as "not set" to allow explicit enabling via NO_COLOR=0
|
||||
noColor := os.Getenv("NO_COLOR")
|
||||
if noColor != "" && noColor != "0" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Default: check TERM - if not "dumb", assume colors are supported
|
||||
return os.Getenv("TERM") != "dumb"
|
||||
}
|
||||
|
||||
// The logic here is inspired from github.com/fatih/color; the following is
|
||||
@@ -103,18 +115,62 @@ const (
|
||||
BgHiWhite
|
||||
)
|
||||
|
||||
// 256-color support
|
||||
// Internal encoding for 256-color codes (used by escape_seq_parser.go):
|
||||
// Foreground 256-color: fg256Start + colorIndex (1000-1255)
|
||||
// Background 256-color: bg256Start + colorIndex (2000-2255)
|
||||
const (
|
||||
// fg256Start is the base value for 256-color foreground colors.
|
||||
// Use Fg256Color(index) to create a 256-color foreground color.
|
||||
fg256Start Color = 1000
|
||||
// bg256Start is the base value for 256-color background colors.
|
||||
// Use Bg256Color(index) to create a 256-color background color.
|
||||
bg256Start Color = 2000
|
||||
)
|
||||
|
||||
// CSSClasses returns the CSS class names for the color.
|
||||
func (c Color) CSSClasses() string {
|
||||
// Check for 256-color and convert to RGB-based class
|
||||
if c >= fg256Start && c < fg256Start+256 {
|
||||
colorIndex := int(c - fg256Start)
|
||||
r, g, b := color256ToRGB(colorIndex)
|
||||
return fmt.Sprintf("fg-256-%d-%d-%d", r, g, b)
|
||||
}
|
||||
if c >= bg256Start && c < bg256Start+256 {
|
||||
colorIndex := int(c - bg256Start)
|
||||
r, g, b := color256ToRGB(colorIndex)
|
||||
return fmt.Sprintf("bg-256-%d-%d-%d", r, g, b)
|
||||
}
|
||||
// Existing behavior for standard colors
|
||||
if class, ok := colorCSSClassMap[c]; ok {
|
||||
return class
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// EscapeSeq returns the ANSI escape sequence for the color.
|
||||
func (c Color) EscapeSeq() string {
|
||||
// Check if it's a 256-color foreground (1000-1255)
|
||||
if c >= fg256Start && c < fg256Start+256 {
|
||||
colorIndex := int(c - fg256Start)
|
||||
return fmt.Sprintf("%s38;5;%d%s", EscapeStart, colorIndex, EscapeStop)
|
||||
}
|
||||
// Check if it's a 256-color background (2000-2255)
|
||||
if c >= bg256Start && c < bg256Start+256 {
|
||||
colorIndex := int(c - bg256Start)
|
||||
return fmt.Sprintf("%s48;5;%d%s", EscapeStart, colorIndex, EscapeStop)
|
||||
}
|
||||
// Regular color (existing behavior)
|
||||
return EscapeStart + strconv.Itoa(int(c)) + EscapeStop
|
||||
}
|
||||
|
||||
// HTMLProperty returns the "class" attribute for the color.
|
||||
func (c Color) HTMLProperty() string {
|
||||
out := ""
|
||||
if class, ok := colorCSSClassMap[c]; ok {
|
||||
out = fmt.Sprintf("class=\"%s\"", class)
|
||||
classes := c.CSSClasses()
|
||||
if classes == "" {
|
||||
return ""
|
||||
}
|
||||
return out
|
||||
return fmt.Sprintf("class=\"%s\"", classes)
|
||||
}
|
||||
|
||||
// Sprint colorizes and prints the given string(s).
|
||||
@@ -134,6 +190,25 @@ type Colors []Color
|
||||
// colorsSeqMap caches the escape sequence for a set of colors
|
||||
var colorsSeqMap = sync.Map{}
|
||||
|
||||
// CSSClasses returns the CSS class names for the colors.
|
||||
func (c Colors) CSSClasses() string {
|
||||
if len(c) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var classes []string
|
||||
for _, color := range c {
|
||||
class := color.CSSClasses()
|
||||
if class != "" {
|
||||
classes = append(classes, class)
|
||||
}
|
||||
}
|
||||
if len(classes) > 1 {
|
||||
sort.Strings(classes)
|
||||
}
|
||||
return strings.Join(classes, " ")
|
||||
}
|
||||
|
||||
// EscapeSeq returns the ANSI escape sequence for the colors set.
|
||||
func (c Colors) EscapeSeq() string {
|
||||
if len(c) == 0 {
|
||||
@@ -143,32 +218,39 @@ func (c Colors) EscapeSeq() string {
|
||||
colorsKey := fmt.Sprintf("%#v", c)
|
||||
escapeSeq, ok := colorsSeqMap.Load(colorsKey)
|
||||
if !ok || escapeSeq == "" {
|
||||
colorNums := make([]string, len(c))
|
||||
for idx, color := range c {
|
||||
colorNums[idx] = strconv.Itoa(int(color))
|
||||
codes := make([]string, 0, len(c))
|
||||
for _, color := range c {
|
||||
codes = append(codes, c.colorToCode(color))
|
||||
}
|
||||
escapeSeq = EscapeStart + strings.Join(colorNums, ";") + EscapeStop
|
||||
escapeSeq = EscapeStart + strings.Join(codes, ";") + EscapeStop
|
||||
colorsSeqMap.Store(colorsKey, escapeSeq)
|
||||
}
|
||||
return escapeSeq.(string)
|
||||
}
|
||||
|
||||
// colorToCode converts a Color to its escape sequence code string.
|
||||
func (c Colors) colorToCode(color Color) string {
|
||||
// Check if it's a 256-color foreground (1000-1255)
|
||||
if color >= fg256Start && color < fg256Start+256 {
|
||||
colorIndex := int(color - fg256Start)
|
||||
return fmt.Sprintf("38;5;%d", colorIndex)
|
||||
}
|
||||
// Check if it's a 256-color background (2000-2255)
|
||||
if color >= bg256Start && color < bg256Start+256 {
|
||||
colorIndex := int(color - bg256Start)
|
||||
return fmt.Sprintf("48;5;%d", colorIndex)
|
||||
}
|
||||
// Regular color
|
||||
return strconv.Itoa(int(color))
|
||||
}
|
||||
|
||||
// HTMLProperty returns the "class" attribute for the colors.
|
||||
func (c Colors) HTMLProperty() string {
|
||||
if len(c) == 0 {
|
||||
classes := c.CSSClasses()
|
||||
if classes == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var classes []string
|
||||
for _, color := range c {
|
||||
if class, ok := colorCSSClassMap[color]; ok {
|
||||
classes = append(classes, class)
|
||||
}
|
||||
}
|
||||
if len(classes) > 1 {
|
||||
sort.Strings(classes)
|
||||
}
|
||||
return fmt.Sprintf("class=\"%s\"", strings.Join(classes, " "))
|
||||
return fmt.Sprintf("class=\"%s\"", classes)
|
||||
}
|
||||
|
||||
// Sprint colorizes and prints the given string(s).
|
||||
@@ -187,3 +269,81 @@ func colorize(s string, escapeSeq string) string {
|
||||
}
|
||||
return Escape(s, escapeSeq)
|
||||
}
|
||||
|
||||
// Fg256Color returns a foreground 256-color Color value.
|
||||
// The index must be in the range 0-255.
|
||||
func Fg256Color(index int) Color {
|
||||
if index < 0 || index > 255 {
|
||||
return Reset
|
||||
}
|
||||
return fg256Start + Color(index)
|
||||
}
|
||||
|
||||
// Bg256Color returns a background 256-color Color value.
|
||||
// The index must be in the range 0-255.
|
||||
func Bg256Color(index int) Color {
|
||||
if index < 0 || index > 255 {
|
||||
return Reset
|
||||
}
|
||||
return bg256Start + Color(index)
|
||||
}
|
||||
|
||||
// Fg256RGB returns a foreground 256-color from RGB values in the 6x6x6 color cube.
|
||||
// Each RGB component must be in the range 0-5.
|
||||
// The resulting color index will be in the range 16-231.
|
||||
func Fg256RGB(r, g, b int) Color {
|
||||
if r < 0 || r > 5 || g < 0 || g > 5 || b < 0 || b > 5 {
|
||||
return Reset
|
||||
}
|
||||
index := 16 + (r*36 + g*6 + b)
|
||||
return Fg256Color(index)
|
||||
}
|
||||
|
||||
// Bg256RGB returns a background 256-color from RGB values in the 6x6x6 color cube.
|
||||
// Each RGB component must be in the range 0-5.
|
||||
// The resulting color index will be in the range 16-231.
|
||||
func Bg256RGB(r, g, b int) Color {
|
||||
if r < 0 || r > 5 || g < 0 || g > 5 || b < 0 || b > 5 {
|
||||
return Reset
|
||||
}
|
||||
index := 16 + (r*36 + g*6 + b)
|
||||
return Bg256Color(index)
|
||||
}
|
||||
|
||||
// color256ToRGB converts a 256-color index to RGB values.
|
||||
// Returns (r, g, b) values in the range 0-255.
|
||||
func color256ToRGB(index int) (r, g, b int) {
|
||||
if index < 16 {
|
||||
// Standard 16 colors - map to predefined RGB values
|
||||
standardColors := [16][3]int{
|
||||
{0, 0, 0}, // 0: black
|
||||
{128, 0, 0}, // 1: red
|
||||
{0, 128, 0}, // 2: green
|
||||
{128, 128, 0}, // 3: yellow
|
||||
{0, 0, 128}, // 4: blue
|
||||
{128, 0, 128}, // 5: magenta
|
||||
{0, 128, 128}, // 6: cyan
|
||||
{192, 192, 192}, // 7: light gray
|
||||
{128, 128, 128}, // 8: dark gray
|
||||
{255, 0, 0}, // 9: bright red
|
||||
{0, 255, 0}, // 10: bright green
|
||||
{255, 255, 0}, // 11: bright yellow
|
||||
{0, 0, 255}, // 12: bright blue
|
||||
{255, 0, 255}, // 13: bright magenta
|
||||
{0, 255, 255}, // 14: bright cyan
|
||||
{255, 255, 255}, // 15: white
|
||||
}
|
||||
return standardColors[index][0], standardColors[index][1], standardColors[index][2]
|
||||
} else if index < 232 {
|
||||
// 216-color RGB cube (16-231)
|
||||
index -= 16
|
||||
r = (index / 36) * 51
|
||||
g = ((index / 6) % 6) * 51
|
||||
b = (index % 6) * 51
|
||||
} else {
|
||||
// 24 grayscale colors (232-255)
|
||||
gray := 8 + (index-232)*10
|
||||
r, g, b = gray, gray, gray
|
||||
}
|
||||
return
|
||||
}
|
||||
+323
-110
@@ -42,73 +42,7 @@ const (
|
||||
escSeqKindOSI
|
||||
)
|
||||
|
||||
type escSeqParser struct {
|
||||
codes map[int]bool
|
||||
|
||||
// consume specific
|
||||
inEscSeq bool
|
||||
escSeqKind escSeqKind
|
||||
escapeSeq string
|
||||
}
|
||||
|
||||
func (s *escSeqParser) Codes() []int {
|
||||
codes := make([]int, 0)
|
||||
for code, val := range s.codes {
|
||||
if val {
|
||||
codes = append(codes, code)
|
||||
}
|
||||
}
|
||||
sort.Ints(codes)
|
||||
return codes
|
||||
}
|
||||
|
||||
func (s *escSeqParser) Consume(char rune) {
|
||||
if !s.inEscSeq && char == EscapeStartRune {
|
||||
s.inEscSeq = true
|
||||
s.escSeqKind = escSeqKindUnknown
|
||||
s.escapeSeq = ""
|
||||
} else if s.inEscSeq && s.escSeqKind == escSeqKindUnknown {
|
||||
if char == EscapeStartRuneCSI {
|
||||
s.escSeqKind = escSeqKindCSI
|
||||
} else if char == EscapeStartRuneOSI {
|
||||
s.escSeqKind = escSeqKindOSI
|
||||
}
|
||||
}
|
||||
|
||||
if s.inEscSeq {
|
||||
s.escapeSeq += string(char)
|
||||
|
||||
// --- FIX for OSC 8 hyperlinks (e.g. \x1b]8;;url\x07label\x1b]8;;\x07)
|
||||
if s.escSeqKind == escSeqKindOSI &&
|
||||
strings.HasPrefix(s.escapeSeq, escapeStartConcealOSI) &&
|
||||
char == '\a' { // BEL
|
||||
|
||||
s.ParseSeq(s.escapeSeq, s.escSeqKind)
|
||||
s.Reset()
|
||||
return
|
||||
}
|
||||
|
||||
if s.isEscapeStopRune(char) {
|
||||
s.ParseSeq(s.escapeSeq, s.escSeqKind)
|
||||
s.Reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *escSeqParser) InSequence() bool {
|
||||
return s.inEscSeq
|
||||
}
|
||||
|
||||
func (s *escSeqParser) IsOpen() bool {
|
||||
return len(s.codes) > 0
|
||||
}
|
||||
|
||||
func (s *escSeqParser) Reset() {
|
||||
s.inEscSeq = false
|
||||
s.escSeqKind = escSeqKindUnknown
|
||||
s.escapeSeq = ""
|
||||
}
|
||||
|
||||
// private constants
|
||||
const (
|
||||
escCodeResetAll = 0
|
||||
escCodeResetIntensity = 22
|
||||
@@ -126,50 +60,132 @@ const (
|
||||
escCodeReverse = 7
|
||||
escCodeConceal = 8
|
||||
escCodeCrossedOut = 9
|
||||
|
||||
// conceal OSI sequences
|
||||
escapeStartConcealOSI = "\x1b]8;"
|
||||
escapeStopConcealOSI = "\x1b\\"
|
||||
)
|
||||
|
||||
func (s *escSeqParser) ParseSeq(seq string, seqKind escSeqKind) {
|
||||
if s.codes == nil {
|
||||
s.codes = make(map[int]bool)
|
||||
// 256-color codes
|
||||
const (
|
||||
escCode256FgStart = 38
|
||||
escCode256BgStart = 48
|
||||
escCode256Color = 5
|
||||
escCodeResetFg = 39
|
||||
escCodeResetBg = 49
|
||||
escCode256Max = 255
|
||||
)
|
||||
|
||||
// Internal encoding for 256-color codes uses fg256Start and bg256Start from color.go
|
||||
// Private constants initialized from private constants to avoid repeated casting in hot paths
|
||||
// Foreground 256-color: fg256Start + colorIndex (1000-1255)
|
||||
// Background 256-color: bg256Start + colorIndex (2000-2255)
|
||||
const (
|
||||
escCode256FgBase = int(fg256Start) // 1000
|
||||
escCode256BgBase = int(bg256Start) // 2000
|
||||
)
|
||||
|
||||
// Standard color code ranges
|
||||
const (
|
||||
// Standard foreground colors (30-37)
|
||||
escCodeFgStdStart = 30
|
||||
escCodeFgStdEnd = 37
|
||||
// Bright foreground colors (90-97)
|
||||
escCodeFgBrightStart = 90
|
||||
escCodeFgBrightEnd = 97
|
||||
// Standard background colors (40-47)
|
||||
escCodeBgStdStart = 40
|
||||
escCodeBgStdEnd = 47
|
||||
// Bright background colors (100-107)
|
||||
escCodeBgBrightStart = 100
|
||||
escCodeBgBrightEnd = 107
|
||||
)
|
||||
|
||||
// Special characters
|
||||
const (
|
||||
escRuneBEL = '\a' // BEL character (ASCII 7)
|
||||
)
|
||||
|
||||
// EscSeqParser parses ANSI escape sequences from text and tracks active formatting codes.
|
||||
// It supports both CSI (Control Sequence Introducer) and OSI (Operating System Command)
|
||||
// escape sequence formats.
|
||||
type EscSeqParser struct {
|
||||
// codes tracks active escape sequence codes (e.g., 1 for bold, 3 for italic).
|
||||
codes map[int]bool
|
||||
|
||||
// inEscSeq indicates whether the parser is currently inside an escape sequence.
|
||||
inEscSeq bool
|
||||
// escSeqKind identifies the type of escape sequence being parsed (CSI or OSI).
|
||||
escSeqKind escSeqKind
|
||||
// escapeSeq accumulates the current escape sequence being parsed.
|
||||
escapeSeq string
|
||||
}
|
||||
|
||||
func (s *EscSeqParser) Codes() []int {
|
||||
codes := make([]int, 0)
|
||||
for code, val := range s.codes {
|
||||
if val {
|
||||
codes = append(codes, code)
|
||||
}
|
||||
}
|
||||
sort.Ints(codes)
|
||||
return codes
|
||||
}
|
||||
|
||||
func (s *EscSeqParser) Consume(char rune) {
|
||||
if !s.inEscSeq && char == EscapeStartRune {
|
||||
s.inEscSeq = true
|
||||
s.escSeqKind = escSeqKindUnknown
|
||||
s.escapeSeq = ""
|
||||
} else if s.inEscSeq && s.escSeqKind == escSeqKindUnknown {
|
||||
switch char {
|
||||
case EscapeStartRuneCSI:
|
||||
s.escSeqKind = escSeqKindCSI
|
||||
case EscapeStartRuneOSI:
|
||||
s.escSeqKind = escSeqKindOSI
|
||||
}
|
||||
}
|
||||
|
||||
if seqKind == escSeqKindOSI {
|
||||
seq = strings.Replace(seq, EscapeStartOSI, "", 1)
|
||||
seq = strings.Replace(seq, EscapeStopOSI, "", 1)
|
||||
} else { // escSeqKindCSI
|
||||
seq = strings.Replace(seq, EscapeStartCSI, "", 1)
|
||||
seq = strings.Replace(seq, EscapeStopCSI, "", 1)
|
||||
}
|
||||
if s.inEscSeq {
|
||||
s.escapeSeq += string(char)
|
||||
|
||||
codes := strings.Split(seq, ";")
|
||||
for _, code := range codes {
|
||||
code = strings.TrimSpace(code)
|
||||
if codeNum, err := strconv.Atoi(code); err == nil {
|
||||
switch codeNum {
|
||||
case escCodeResetAll:
|
||||
s.codes = make(map[int]bool) // clear everything
|
||||
case escCodeResetIntensity:
|
||||
delete(s.codes, escCodeBold)
|
||||
delete(s.codes, escCodeDim)
|
||||
case escCodeResetItalic:
|
||||
delete(s.codes, escCodeItalic)
|
||||
case escCodeResetUnderline:
|
||||
delete(s.codes, escCodeUnderline)
|
||||
case escCodeResetBlink:
|
||||
delete(s.codes, escCodeBlinkSlow)
|
||||
delete(s.codes, escCodeBlinkRapid)
|
||||
case escCodeResetReverse:
|
||||
delete(s.codes, escCodeReverse)
|
||||
case escCodeResetCrossedOut:
|
||||
delete(s.codes, escCodeCrossedOut)
|
||||
default:
|
||||
s.codes[codeNum] = true
|
||||
}
|
||||
// --- FIX for OSC 8 hyperlinks (e.g. \x1b]8;;url\x07label\x1b]8;;\x07)
|
||||
if s.escSeqKind == escSeqKindOSI &&
|
||||
strings.HasPrefix(s.escapeSeq, escapeStartConcealOSI) &&
|
||||
char == escRuneBEL { // BEL
|
||||
|
||||
s.ParseSeq(s.escapeSeq, s.escSeqKind)
|
||||
s.Reset()
|
||||
return
|
||||
}
|
||||
|
||||
if s.isEscapeStopRune(char) {
|
||||
s.ParseSeq(s.escapeSeq, s.escSeqKind)
|
||||
s.Reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *escSeqParser) ParseString(str string) string {
|
||||
func (s *EscSeqParser) InSequence() bool {
|
||||
return s.inEscSeq
|
||||
}
|
||||
|
||||
func (s *EscSeqParser) IsOpen() bool {
|
||||
return len(s.codes) > 0
|
||||
}
|
||||
|
||||
func (s *EscSeqParser) ParseSeq(seq string, seqKind escSeqKind) {
|
||||
if s.codes == nil {
|
||||
s.codes = make(map[int]bool)
|
||||
}
|
||||
|
||||
seq = s.stripEscapeSequence(seq, seqKind)
|
||||
codes := s.splitAndTrimCodes(seq)
|
||||
processed256ColorIndices := s.process256ColorSequences(codes)
|
||||
s.processRegularCodes(codes, processed256ColorIndices)
|
||||
}
|
||||
|
||||
func (s *EscSeqParser) ParseString(str string) string {
|
||||
s.escapeSeq, s.inEscSeq, s.escSeqKind = "", false, escSeqKindUnknown
|
||||
for _, char := range str {
|
||||
s.Consume(char)
|
||||
@@ -177,15 +193,33 @@ func (s *escSeqParser) ParseString(str string) string {
|
||||
return s.Sequence()
|
||||
}
|
||||
|
||||
func (s *escSeqParser) Sequence() string {
|
||||
func (s *EscSeqParser) Reset() {
|
||||
s.inEscSeq = false
|
||||
s.escSeqKind = escSeqKindUnknown
|
||||
s.escapeSeq = ""
|
||||
}
|
||||
|
||||
func (s *EscSeqParser) Sequence() string {
|
||||
out := strings.Builder{}
|
||||
if s.IsOpen() {
|
||||
out.WriteString(EscapeStart)
|
||||
for idx, code := range s.Codes() {
|
||||
codes := s.Codes()
|
||||
for idx, code := range codes {
|
||||
if idx > 0 {
|
||||
out.WriteRune(';')
|
||||
}
|
||||
out.WriteString(fmt.Sprint(code))
|
||||
// Check if this is a 256-color foreground code (1000-1255)
|
||||
if code >= escCode256FgBase && code <= escCode256FgBase+escCode256Max {
|
||||
colorIndex := code - escCode256FgBase
|
||||
out.WriteString(fmt.Sprintf("%d;%d;%d", escCode256FgStart, escCode256Color, colorIndex))
|
||||
} else if code >= escCode256BgBase && code <= escCode256BgBase+escCode256Max {
|
||||
// 256-color background code (2000-2255)
|
||||
colorIndex := code - escCode256BgBase
|
||||
out.WriteString(fmt.Sprintf("%d;%d;%d", escCode256BgStart, escCode256Color, colorIndex))
|
||||
} else {
|
||||
// Regular code
|
||||
out.WriteString(fmt.Sprint(code))
|
||||
}
|
||||
}
|
||||
out.WriteString(EscapeStop)
|
||||
}
|
||||
@@ -193,12 +227,54 @@ func (s *escSeqParser) Sequence() string {
|
||||
return out.String()
|
||||
}
|
||||
|
||||
const (
|
||||
escapeStartConcealOSI = "\x1b]8;"
|
||||
escapeStopConcealOSI = "\x1b\\"
|
||||
)
|
||||
// clearAllBackgroundColors clears all background color codes.
|
||||
func (s *EscSeqParser) clearAllBackgroundColors() {
|
||||
for code := escCodeBgStdStart; code <= escCodeBgStdEnd; code++ {
|
||||
delete(s.codes, code)
|
||||
}
|
||||
for code := escCodeBgBrightStart; code <= escCodeBgBrightEnd; code++ {
|
||||
delete(s.codes, code)
|
||||
}
|
||||
for code := escCode256BgBase; code <= escCode256BgBase+escCode256Max; code++ {
|
||||
delete(s.codes, code)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *escSeqParser) isEscapeStopRune(char rune) bool {
|
||||
// clearAllForegroundColors clears all foreground color codes.
|
||||
func (s *EscSeqParser) clearAllForegroundColors() {
|
||||
for code := escCodeFgStdStart; code <= escCodeFgStdEnd; code++ {
|
||||
delete(s.codes, code)
|
||||
}
|
||||
for code := escCodeFgBrightStart; code <= escCodeFgBrightEnd; code++ {
|
||||
delete(s.codes, code)
|
||||
}
|
||||
for code := escCode256FgBase; code <= escCode256FgBase+escCode256Max; code++ {
|
||||
delete(s.codes, code)
|
||||
}
|
||||
}
|
||||
|
||||
// clearColorRange clears standard foreground or background colors.
|
||||
func (s *EscSeqParser) clearColorRange(isForeground bool) {
|
||||
if isForeground {
|
||||
// Clear standard foreground colors (30-37, 90-97)
|
||||
for code := escCodeFgStdStart; code <= escCodeFgStdEnd; code++ {
|
||||
delete(s.codes, code)
|
||||
}
|
||||
for code := escCodeFgBrightStart; code <= escCodeFgBrightEnd; code++ {
|
||||
delete(s.codes, code)
|
||||
}
|
||||
} else {
|
||||
// Clear standard background colors (40-47, 100-107)
|
||||
for code := escCodeBgStdStart; code <= escCodeBgStdEnd; code++ {
|
||||
delete(s.codes, code)
|
||||
}
|
||||
for code := escCodeBgBrightStart; code <= escCodeBgBrightEnd; code++ {
|
||||
delete(s.codes, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *EscSeqParser) isEscapeStopRune(char rune) bool {
|
||||
if strings.HasPrefix(s.escapeSeq, escapeStartConcealOSI) {
|
||||
if strings.HasSuffix(s.escapeSeq, escapeStopConcealOSI) {
|
||||
return true
|
||||
@@ -209,3 +285,140 @@ func (s *escSeqParser) isEscapeStopRune(char rune) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isRegularCode checks if a code is a regular code (not a 256-color encoded value).
|
||||
func (s *EscSeqParser) isRegularCode(codeNum int) bool {
|
||||
return codeNum < escCode256FgBase || codeNum > escCode256BgBase+escCode256Max
|
||||
}
|
||||
|
||||
// parse256ColorSequence attempts to parse a 256-color sequence starting at index i.
|
||||
// Returns (colorIndex, base, true) if valid, or (0, 0, false) if not.
|
||||
func (s *EscSeqParser) parse256ColorSequence(codes []string, i int) (colorIndex int, base int, ok bool) {
|
||||
if i+2 >= len(codes) {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
codeNum, err := strconv.Atoi(codes[i])
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
var expectedBase int
|
||||
switch codeNum {
|
||||
case escCode256FgStart:
|
||||
expectedBase = escCode256FgBase
|
||||
case escCode256BgStart:
|
||||
expectedBase = escCode256BgBase
|
||||
default:
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
nextCode, err := strconv.Atoi(codes[i+1])
|
||||
if err != nil || nextCode != escCode256Color {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
colorIndex, err = strconv.Atoi(codes[i+2])
|
||||
if err != nil || colorIndex < 0 || colorIndex > escCode256Max {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
return colorIndex, expectedBase, true
|
||||
}
|
||||
|
||||
// process256ColorSequences processes 256-color sequences (38;5;n or 48;5;n) and returns
|
||||
// a map of indices that were part of valid 256-color sequences.
|
||||
func (s *EscSeqParser) process256ColorSequences(codes []string) map[int]bool {
|
||||
processedIndices := make(map[int]bool)
|
||||
for i := 0; i < len(codes); i++ {
|
||||
if colorIndex, base, ok := s.parse256ColorSequence(codes, i); ok {
|
||||
s.set256Color(base, colorIndex)
|
||||
s.clearColorRange(base == escCode256FgBase)
|
||||
processedIndices[i] = true
|
||||
processedIndices[i+1] = true
|
||||
processedIndices[i+2] = true
|
||||
i += 2 // Skip i+1 and i+2 (loop will increment to i+3)
|
||||
}
|
||||
}
|
||||
return processedIndices
|
||||
}
|
||||
|
||||
// processCode handles a single escape code.
|
||||
func (s *EscSeqParser) processCode(codeNum int) {
|
||||
switch codeNum {
|
||||
case escCodeResetAll:
|
||||
s.codes = make(map[int]bool)
|
||||
case escCodeResetIntensity:
|
||||
delete(s.codes, escCodeBold)
|
||||
delete(s.codes, escCodeDim)
|
||||
case escCodeResetItalic:
|
||||
delete(s.codes, escCodeItalic)
|
||||
case escCodeResetUnderline:
|
||||
delete(s.codes, escCodeUnderline)
|
||||
case escCodeResetBlink:
|
||||
delete(s.codes, escCodeBlinkSlow)
|
||||
delete(s.codes, escCodeBlinkRapid)
|
||||
case escCodeResetReverse:
|
||||
delete(s.codes, escCodeReverse)
|
||||
case escCodeResetCrossedOut:
|
||||
delete(s.codes, escCodeCrossedOut)
|
||||
case escCodeResetFg:
|
||||
s.clearAllForegroundColors()
|
||||
case escCodeResetBg:
|
||||
s.clearAllBackgroundColors()
|
||||
default:
|
||||
if s.isRegularCode(codeNum) {
|
||||
s.codes[codeNum] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// processRegularCodes processes regular escape codes and reset codes.
|
||||
func (s *EscSeqParser) processRegularCodes(codes []string, processedIndices map[int]bool) {
|
||||
for i, code := range codes {
|
||||
if processedIndices[i] {
|
||||
continue
|
||||
}
|
||||
|
||||
codeNum, err := strconv.Atoi(code)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
s.processCode(codeNum)
|
||||
}
|
||||
}
|
||||
|
||||
// set256Color sets a 256-color code and clears conflicting colors.
|
||||
func (s *EscSeqParser) set256Color(base int, colorIndex int) {
|
||||
encodedValue := base + colorIndex
|
||||
s.codes[encodedValue] = true
|
||||
|
||||
// Clear other colors in the same range
|
||||
for code := base; code <= base+escCode256Max; code++ {
|
||||
if code != encodedValue {
|
||||
delete(s.codes, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// splitAndTrimCodes splits the sequence by semicolons and trims whitespace.
|
||||
func (s *EscSeqParser) splitAndTrimCodes(seq string) []string {
|
||||
codes := strings.Split(seq, ";")
|
||||
for i := range codes {
|
||||
codes[i] = strings.TrimSpace(codes[i])
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
// stripEscapeSequence removes escape sequence markers from the input string.
|
||||
func (s *EscSeqParser) stripEscapeSequence(seq string, seqKind escSeqKind) string {
|
||||
if seqKind == escSeqKindOSI {
|
||||
seq = strings.Replace(seq, EscapeStartOSI, "", 1)
|
||||
seq = strings.Replace(seq, EscapeStopOSI, "", 1)
|
||||
} else {
|
||||
seq = strings.Replace(seq, EscapeStartCSI, "", 1)
|
||||
seq = strings.Replace(seq, EscapeStopCSI, "", 1)
|
||||
}
|
||||
return seq
|
||||
}
|
||||
+32
-17
@@ -28,7 +28,7 @@ func InsertEveryN(str string, runeToInsert rune, n int) string {
|
||||
sLen := StringWidthWithoutEscSequences(str)
|
||||
var out strings.Builder
|
||||
out.Grow(sLen + (sLen / n))
|
||||
outLen, esp := 0, escSeqParser{}
|
||||
outLen, esp := 0, EscSeqParser{}
|
||||
for idx, c := range str {
|
||||
if esp.InSequence() {
|
||||
esp.Consume(c)
|
||||
@@ -52,7 +52,7 @@ func InsertEveryN(str string, runeToInsert rune, n int) string {
|
||||
//
|
||||
// LongestLineLen("Ghost!\nCome back here!\nRight now!") == 15
|
||||
func LongestLineLen(str string) int {
|
||||
maxLength, currLength, esp := 0, 0, escSeqParser{}
|
||||
maxLength, currLength, esp := 0, 0, EscSeqParser{}
|
||||
//fmt.Println(str)
|
||||
for _, c := range str {
|
||||
//fmt.Printf("%03d | %03d | %c | %5v | %v | %#v\n", idx, c, c, esp.inEscSeq, esp.Codes(), esp.escapeSeq)
|
||||
@@ -76,17 +76,20 @@ func LongestLineLen(str string) int {
|
||||
return maxLength
|
||||
}
|
||||
|
||||
// OverrideRuneWidthEastAsianWidth can *probably* help with alignment, and
|
||||
// length calculation issues when dealing with Unicode character-set and a
|
||||
// non-English language set in the LANG variable.
|
||||
// OverrideRuneWidthEastAsianWidth overrides the East Asian width detection in
|
||||
// the runewidth library. This is primarily for advanced use cases.
|
||||
//
|
||||
// Set this to 'false' to force the "runewidth" library to pretend to deal with
|
||||
// English character-set. Be warned that if the text/content you are dealing
|
||||
// with contains East Asian character-set, this may result in unexpected
|
||||
// behavior.
|
||||
// Box drawing (U+2500-U+257F) and block element (U+2580-U+259F) characters
|
||||
// are automatically handled and always reported as width 1, regardless of
|
||||
// this setting, fixing alignment issues that previously required setting this
|
||||
// to false.
|
||||
//
|
||||
// References:
|
||||
// * https://github.com/mattn/go-runewidth/issues/64#issuecomment-1221642154
|
||||
// Setting this to false forces runewidth to treat all characters as if in an
|
||||
// English locale. Warning: this may cause East Asian characters (Chinese,
|
||||
// Japanese, Korean) to be incorrectly reported as width 1 instead of 2.
|
||||
//
|
||||
// See:
|
||||
// * https://github.com/mattn/go-runewidth/issues/64
|
||||
// * https://github.com/jedib0t/go-pretty/issues/220
|
||||
// * https://github.com/jedib0t/go-pretty/issues/204
|
||||
func OverrideRuneWidthEastAsianWidth(val bool) {
|
||||
@@ -184,16 +187,28 @@ func RuneCount(str string) int {
|
||||
return StringWidthWithoutEscSequences(str)
|
||||
}
|
||||
|
||||
// RuneWidth returns the mostly accurate character-width of the rune. This is
|
||||
// not 100% accurate as the character width is usually dependent on the
|
||||
// typeface (font) used in the console/terminal. For ex.:
|
||||
// RuneWidth returns the display width of a rune. Width accuracy depends on
|
||||
// the terminal font, as character width is font-dependent. Examples:
|
||||
//
|
||||
// RuneWidth('A') == 1
|
||||
// RuneWidth('ツ') == 2
|
||||
// RuneWidth('⊙') == 1
|
||||
// RuneWidth('︿') == 2
|
||||
// RuneWidth(0x27) == 0
|
||||
//
|
||||
// Box drawing (U+2500-U+257F) and block element (U+2580-U+259F) characters
|
||||
// are always treated as width 1, regardless of locale, to ensure proper
|
||||
// alignment in tables and progress indicators. This fixes incorrect width 2
|
||||
// reporting in East Asian locales (e.g., LANG=zh_CN.UTF-8).
|
||||
//
|
||||
// See:
|
||||
// * https://github.com/mattn/go-runewidth/issues/64
|
||||
// * https://github.com/jedib0t/go-pretty/issues/220
|
||||
// * https://github.com/jedib0t/go-pretty/issues/204
|
||||
func RuneWidth(r rune) int {
|
||||
if (r >= 0x2500 && r <= 0x257F) || (r >= 0x2580 && r <= 0x259F) {
|
||||
return 1
|
||||
}
|
||||
return rwCondition.RuneWidth(r)
|
||||
}
|
||||
|
||||
@@ -248,7 +263,7 @@ func StringWidth(str string) int {
|
||||
// StringWidthWithoutEscSequences("Ghost 生命"): 10
|
||||
// StringWidthWithoutEscSequences("\x1b[33mGhost 生命\x1b[0m"): 10
|
||||
func StringWidthWithoutEscSequences(str string) int {
|
||||
count, esp := 0, escSeqParser{}
|
||||
count, esp := 0, EscSeqParser{}
|
||||
for _, c := range str {
|
||||
if esp.InSequence() {
|
||||
esp.Consume(c)
|
||||
@@ -277,7 +292,7 @@ func Trim(str string, maxLen int) string {
|
||||
var out strings.Builder
|
||||
out.Grow(maxLen)
|
||||
|
||||
outLen, esp := 0, escSeqParser{}
|
||||
outLen, esp := 0, EscSeqParser{}
|
||||
for _, sChr := range str {
|
||||
if esp.InSequence() {
|
||||
esp.Consume(sChr)
|
||||
@@ -306,7 +321,7 @@ func Widen(str string) string {
|
||||
sb := strings.Builder{}
|
||||
sb.Grow(len(str))
|
||||
|
||||
esp := escSeqParser{}
|
||||
esp := EscSeqParser{}
|
||||
for _, c := range str {
|
||||
if esp.InSequence() {
|
||||
sb.WriteRune(c)
|
||||
|
||||
Loaded 100 of 507 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user