mirror of
https://github.com/BishopFox/sliver.git
synced 2026-11-07 17:08:13 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da3347c8c3 |
No files matched your search
@@ -9,7 +9,6 @@ run:
|
||||
- osusergo
|
||||
- netgo
|
||||
- go_sqlite
|
||||
- sliver_generate
|
||||
# Raw implant entry points are Go templates; lint their supporting packages.
|
||||
- sliver_lint
|
||||
|
||||
|
||||
@@ -199,7 +199,7 @@ clean-all: clean
|
||||
clean:
|
||||
rm -f sliver-client sliver-client_* sliver-server sliver-server_* sliver-*.exe
|
||||
|
||||
.downloaded_assets: util/assets/constants.go util/assets/garble.go
|
||||
.downloaded_assets:
|
||||
$(ENV) $(GO) run -mod=vendor ./util/cmd/assets
|
||||
touch ./.downloaded_assets
|
||||
|
||||
@@ -324,7 +324,7 @@ clean-all: clean
|
||||
clean:
|
||||
-del /Q /F sliver-client sliver-client_* sliver-server sliver-server_* sliver-*.exe 2>NUL
|
||||
|
||||
.downloaded_assets: util/assets/constants.go util/assets/garble.go
|
||||
.downloaded_assets:
|
||||
$(call windows_exec,$(ENV),"$(GO)" run -mod=vendor ./util/cmd/assets)
|
||||
@type NUL > .downloaded_assets
|
||||
|
||||
|
||||
@@ -156,6 +156,13 @@ func TestMessageBlockLabelIgnoresUnknownOperatorPlaceholders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscriptSpeakerStyleUsesPrimaryThemeForUserMessages(t *testing.T) {
|
||||
styles := transcriptSpeakerStyle("alice", "user")
|
||||
|
||||
assertColorEqual(t, styles.border.GetForeground(), clienttheme.Primary())
|
||||
assertColorEqual(t, styles.label.GetBackground(), clienttheme.Primary())
|
||||
}
|
||||
|
||||
func TestThinkingLevelOptionFocusedStyleUsesDarkerPrimaryShade(t *testing.T) {
|
||||
model := newAIModel(nil, aiContext{}, nil)
|
||||
|
||||
@@ -537,12 +544,19 @@ func TestRenderHeaderUsesSingleCompactRow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscriptSpeakerPaletteVariesAcrossUsers(t *testing.T) {
|
||||
seen := map[int]struct{}{}
|
||||
for _, label := range []string{"alice", "bob", "charlie", "dana", "erin", "frank"} {
|
||||
seen[transcriptSpeakerPaletteIndex(label, "user")] = struct{}{}
|
||||
}
|
||||
if len(seen) < 2 {
|
||||
t.Fatalf("expected distinct users to map to more than one transcript color, got %d palette entries", len(seen))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscriptSpeakerStyleUsesPrimaryBorderForUserMessages(t *testing.T) {
|
||||
userStyles := transcriptSpeakerStyle("alice", "user")
|
||||
assertColorEqual(t, userStyles.border.GetForeground(), clienttheme.Primary())
|
||||
assertColorEqual(t, userStyles.label.GetBackground(), clienttheme.Primary())
|
||||
|
||||
userBorder := userStyles.border.Render("│")
|
||||
userBorder := transcriptSpeakerStyle("alice", "user").border.Render("│")
|
||||
wantUserBorder := lipgloss.NewStyle().Foreground(clienttheme.Primary()).Render("│")
|
||||
if userBorder != wantUserBorder {
|
||||
t.Fatalf("expected user message border to use theme primary color")
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
package armory
|
||||
@@ -3,8 +3,6 @@ package exec
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
consts "github.com/bishopfox/sliver/client/constants"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -89,11 +87,14 @@ func TestShouldConvertExecuteShellcodePE(t *testing.T) {
|
||||
func newExecuteShellcodeFlagsTestCommand(t *testing.T) *cobra.Command {
|
||||
t.Helper()
|
||||
|
||||
for _, cmd := range Commands(&console.SliverClient{}) {
|
||||
if cmd.Name() == consts.ExecuteShellcodeStr {
|
||||
return cmd
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing %q command", consts.ExecuteShellcodeStr)
|
||||
return nil
|
||||
cmd := &cobra.Command{Use: "execute-shellcode"}
|
||||
cmd.Flags().Uint32("shellcode-entropy", 1, "")
|
||||
cmd.Flags().Bool("shellcode-compress", false, "")
|
||||
cmd.Flags().Uint32("shellcode-exitopt", 1, "")
|
||||
cmd.Flags().Uint32("shellcode-bypass", 3, "")
|
||||
cmd.Flags().Uint32("shellcode-headers", 1, "")
|
||||
cmd.Flags().Bool("shellcode-thread", false, "")
|
||||
cmd.Flags().Bool("shellcode-unicode", false, "")
|
||||
cmd.Flags().Uint32("shellcode-oep", 0, "")
|
||||
return cmd
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package generate
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/bishopfox/sliver/client/command/flags"
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
consts "github.com/bishopfox/sliver/client/constants"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCanariesCommandRegistered(t *testing.T) {
|
||||
root := &cobra.Command{Use: "sliver"}
|
||||
root.AddCommand(Commands(&console.SliverClient{})...)
|
||||
|
||||
cmd, _, err := root.Find([]string{consts.CanariesStr})
|
||||
if err != nil {
|
||||
t.Fatalf("find canaries command: %v", err)
|
||||
}
|
||||
if cmd.Parent() != root {
|
||||
t.Fatal("canaries command is not registered at the root")
|
||||
}
|
||||
if cmd.Run == nil {
|
||||
t.Fatal("canaries command has no handler")
|
||||
}
|
||||
if cmd.GroupID != consts.PayloadsHelpGroup {
|
||||
t.Errorf("canaries group = %q, want %q", cmd.GroupID, consts.PayloadsHelpGroup)
|
||||
}
|
||||
|
||||
for _, want := range []struct {
|
||||
name string
|
||||
shorthand string
|
||||
valueType string
|
||||
defaultValue string
|
||||
}{
|
||||
{name: "burned", shorthand: "b", valueType: "bool", defaultValue: "false"},
|
||||
{name: "timeout", shorthand: "t", valueType: "int64", defaultValue: strconv.Itoa(flags.DefaultTimeout)},
|
||||
} {
|
||||
flag := cmd.Flags().Lookup(want.name)
|
||||
if flag == nil {
|
||||
t.Fatalf("canaries command is missing --%s", want.name)
|
||||
}
|
||||
if flag.Shorthand != want.shorthand {
|
||||
t.Errorf("--%s shorthand = %q, want %q", want.name, flag.Shorthand, want.shorthand)
|
||||
}
|
||||
if flag.Value.Type() != want.valueType {
|
||||
t.Errorf("--%s type = %q, want %q", want.name, flag.Value.Type(), want.valueType)
|
||||
}
|
||||
if flag.DefValue != want.defaultValue {
|
||||
t.Errorf("--%s default = %q, want %q", want.name, flag.DefValue, want.defaultValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -313,7 +313,7 @@ func Commands(con *console.SliverClient) []*cobra.Command {
|
||||
f.Int64P("timeout", "t", flags.DefaultTimeout, "grpc timeout in seconds")
|
||||
})
|
||||
|
||||
return []*cobra.Command{generateCmd, regenerateCmd, profilesCmd, implantBuildsCmd, canariesCmd}
|
||||
return []*cobra.Command{generateCmd, regenerateCmd, profilesCmd, implantBuildsCmd}
|
||||
}
|
||||
|
||||
// coreImplantFlags binds all flags common to all sliver implant types.
|
||||
|
||||
@@ -3,77 +3,57 @@ package console
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
)
|
||||
|
||||
func TestRemoveEventListenerUnblocksInFlightDispatchAndClosesReceiver(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
con := &SliverClient{EventListeners: &sync.Map{}}
|
||||
listenerID, events := con.CreateEventListener()
|
||||
value, ok := con.EventListeners.Load(listenerID)
|
||||
if !ok {
|
||||
t.Fatal("created listener is not registered")
|
||||
}
|
||||
listener := value.(*consoleEventListener)
|
||||
for index := 0; index < cap(listener.events); index++ {
|
||||
listener.events <- &clientpb.Event{EventType: "buffered"}
|
||||
}
|
||||
|
||||
dispatchDone := make(chan struct{})
|
||||
go func() {
|
||||
listener.send(&clientpb.Event{EventType: "in-flight"})
|
||||
close(dispatchDone)
|
||||
}()
|
||||
|
||||
con.RemoveEventListener(listenerID)
|
||||
select {
|
||||
case <-dispatchDone:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("listener removal did not unblock in-flight dispatch")
|
||||
}
|
||||
if _, stillRegistered := con.EventListeners.Load(listenerID); stillRegistered {
|
||||
t.Fatal("removed listener remains registered")
|
||||
}
|
||||
|
||||
for event := range events {
|
||||
_ = event
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentEventDispatchAndRemovalDoesNotPanic(_ *testing.T) {
|
||||
for iteration := 0; iteration < 1_000; iteration++ {
|
||||
con := &SliverClient{EventListeners: &sync.Map{}}
|
||||
listenerID, events := con.CreateEventListener()
|
||||
value, ok := con.EventListeners.Load(listenerID)
|
||||
if !ok {
|
||||
t.Fatal("created listener is not registered")
|
||||
}
|
||||
listener := value.(*consoleEventListener)
|
||||
buffered := &clientpb.Event{EventType: "buffered"}
|
||||
for index := 0; index < cap(listener.events); index++ {
|
||||
listener.events <- buffered
|
||||
}
|
||||
|
||||
dispatchDone := make(chan struct{})
|
||||
go func() {
|
||||
event := &clientpb.Event{EventType: "in-flight"}
|
||||
con.triggerEventListeners(event)
|
||||
con.triggerEventListeners(&clientpb.Event{EventType: "race"})
|
||||
close(dispatchDone)
|
||||
}()
|
||||
|
||||
// The full buffer and Wait establish that send is blocked before removal.
|
||||
synctest.Wait()
|
||||
select {
|
||||
case <-dispatchDone:
|
||||
t.Fatal("dispatch completed before listener removal")
|
||||
default:
|
||||
con.RemoveEventListener(listenerID)
|
||||
<-dispatchDone
|
||||
for event := range events {
|
||||
_ = event
|
||||
}
|
||||
|
||||
removeDone := make(chan struct{})
|
||||
go func() {
|
||||
con.RemoveEventListener(listenerID)
|
||||
// Dispatch and repeated removal after closure must remain harmless.
|
||||
con.triggerEventListeners(&clientpb.Event{EventType: "removed"})
|
||||
listener.send(&clientpb.Event{EventType: "closed"})
|
||||
con.RemoveEventListener(listenerID)
|
||||
close(removeDone)
|
||||
}()
|
||||
for _, done := range []<-chan struct{}{dispatchDone, removeDone} {
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("listener removal and in-flight dispatch did not complete")
|
||||
}
|
||||
}
|
||||
if _, stillRegistered := con.EventListeners.Load(listenerID); stillRegistered {
|
||||
t.Fatal("removed listener remains registered")
|
||||
}
|
||||
|
||||
deadline := time.After(time.Second)
|
||||
for received := 0; ; received++ {
|
||||
select {
|
||||
case event, open := <-events:
|
||||
if !open {
|
||||
if received != cap(listener.events) {
|
||||
t.Fatalf("received %d buffered events, want %d", received, cap(listener.events))
|
||||
}
|
||||
return
|
||||
}
|
||||
if event != buffered {
|
||||
t.Fatalf("unexpected event after removal: %v", event)
|
||||
}
|
||||
case <-deadline:
|
||||
t.Fatal("removed listener receiver did not close")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+105
-95
@@ -16,103 +16,113 @@ func TestMultiplayerConnectModeDefaultsToAuto(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectMultiplayerDialStrategy(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mode MultiplayerConnectMode
|
||||
config *assets.ClientConfig
|
||||
want multiplayerDialStrategy
|
||||
wantErr error
|
||||
wantErrText string
|
||||
}{
|
||||
{
|
||||
name: "auto without WireGuard config",
|
||||
mode: MultiplayerConnectAuto,
|
||||
config: &assets.ClientConfig{},
|
||||
want: multiplayerDialDirect,
|
||||
func TestSelectMultiplayerDialStrategyAutoWithoutWGConfigUsesDirectMTLS(t *testing.T) {
|
||||
setTestMultiplayerConnectMode(t, MultiplayerConnectAuto)
|
||||
|
||||
strategy, err := selectMultiplayerDialStrategy(&assets.ClientConfig{})
|
||||
if err != nil {
|
||||
t.Fatalf("select dial strategy: %v", err)
|
||||
}
|
||||
if strategy != multiplayerDialDirect {
|
||||
t.Fatalf("expected direct mTLS strategy, got %v", strategy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectMultiplayerDialStrategyAutoLegacyWGConfigUsesDirectMTLS(t *testing.T) {
|
||||
setTestMultiplayerConnectMode(t, MultiplayerConnectAuto)
|
||||
|
||||
strategy, err := selectMultiplayerDialStrategy(completeWGClientConfig(false))
|
||||
if err != nil {
|
||||
t.Fatalf("select dial strategy: %v", err)
|
||||
}
|
||||
if strategy != multiplayerDialDirect {
|
||||
t.Fatalf("expected direct mTLS strategy, got %v", strategy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectMultiplayerDialStrategyAutoEnabledWGConfigRequiresCompleteConfig(t *testing.T) {
|
||||
setTestMultiplayerConnectMode(t, MultiplayerConnectAuto)
|
||||
|
||||
_, err := selectMultiplayerDialStrategy(&assets.ClientConfig{
|
||||
WG: &assets.ClientWGConfig{
|
||||
Enabled: true,
|
||||
ServerPubKey: "server-pub",
|
||||
ClientIP: "100.64.0.2",
|
||||
},
|
||||
{
|
||||
name: "auto with legacy WireGuard config",
|
||||
mode: MultiplayerConnectAuto,
|
||||
config: completeWGClientConfig(false),
|
||||
want: multiplayerDialDirect,
|
||||
})
|
||||
if !errors.Is(err, ErrIncompleteWireGuardConfig) {
|
||||
t.Fatalf("expected incomplete WG config error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectMultiplayerDialStrategyAutoEnabledWGConfigUsesWireGuard(t *testing.T) {
|
||||
setTestMultiplayerConnectMode(t, MultiplayerConnectAuto)
|
||||
|
||||
strategy, err := selectMultiplayerDialStrategy(completeWGClientConfig(true))
|
||||
if err != nil {
|
||||
t.Fatalf("select dial strategy: %v", err)
|
||||
}
|
||||
if strategy != multiplayerDialWireGuard {
|
||||
t.Fatalf("expected WireGuard strategy, got %v", strategy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectMultiplayerDialStrategyEnableWGRejectsMissingWGConfig(t *testing.T) {
|
||||
setTestMultiplayerConnectMode(t, MultiplayerConnectEnableWG)
|
||||
|
||||
_, err := selectMultiplayerDialStrategy(&assets.ClientConfig{})
|
||||
if !errors.Is(err, ErrMissingWireGuardConfig) {
|
||||
t.Fatalf("expected missing WG config error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectMultiplayerDialStrategyEnableWGRejectsIncompleteWGConfig(t *testing.T) {
|
||||
setTestMultiplayerConnectMode(t, MultiplayerConnectEnableWG)
|
||||
|
||||
_, err := selectMultiplayerDialStrategy(&assets.ClientConfig{
|
||||
WG: &assets.ClientWGConfig{
|
||||
ServerPubKey: "server-pub",
|
||||
ClientIP: "100.64.0.2",
|
||||
},
|
||||
{
|
||||
name: "auto with incomplete enabled config",
|
||||
mode: MultiplayerConnectAuto,
|
||||
config: &assets.ClientConfig{WG: &assets.ClientWGConfig{
|
||||
Enabled: true, ServerPubKey: "server-pub", ClientIP: "100.64.0.2",
|
||||
}},
|
||||
want: multiplayerDialDirect,
|
||||
wantErr: ErrIncompleteWireGuardConfig,
|
||||
},
|
||||
{
|
||||
name: "auto with enabled WireGuard config",
|
||||
mode: MultiplayerConnectAuto,
|
||||
config: completeWGClientConfig(true),
|
||||
want: multiplayerDialWireGuard,
|
||||
},
|
||||
{
|
||||
name: "enable with missing WireGuard config",
|
||||
mode: MultiplayerConnectEnableWG,
|
||||
config: &assets.ClientConfig{},
|
||||
want: multiplayerDialDirect,
|
||||
wantErr: ErrMissingWireGuardConfig,
|
||||
},
|
||||
{
|
||||
name: "enable with incomplete WireGuard config",
|
||||
mode: MultiplayerConnectEnableWG,
|
||||
config: &assets.ClientConfig{WG: &assets.ClientWGConfig{
|
||||
ServerPubKey: "server-pub", ClientIP: "100.64.0.2",
|
||||
}},
|
||||
want: multiplayerDialDirect,
|
||||
wantErr: ErrIncompleteWireGuardConfig,
|
||||
},
|
||||
{
|
||||
name: "enable with complete legacy config",
|
||||
mode: MultiplayerConnectEnableWG,
|
||||
config: completeWGClientConfig(false),
|
||||
want: multiplayerDialWireGuard,
|
||||
},
|
||||
{
|
||||
name: "disable overrides enabled config",
|
||||
mode: MultiplayerConnectDisableWG,
|
||||
config: completeWGClientConfig(true),
|
||||
want: multiplayerDialDirect,
|
||||
},
|
||||
{
|
||||
name: "invalid mode",
|
||||
mode: MultiplayerConnectMode(255),
|
||||
config: completeWGClientConfig(true),
|
||||
want: multiplayerDialDirect,
|
||||
wantErrText: "invalid multiplayer connect mode 255",
|
||||
},
|
||||
{
|
||||
name: "nil config",
|
||||
mode: MultiplayerConnectAuto,
|
||||
want: multiplayerDialDirect,
|
||||
wantErrText: "client config is required",
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
setTestMultiplayerConnectMode(t, test.mode)
|
||||
strategy, err := selectMultiplayerDialStrategy(test.config)
|
||||
if strategy != test.want {
|
||||
t.Errorf("dial strategy = %v, want %v", strategy, test.want)
|
||||
}
|
||||
switch {
|
||||
case test.wantErr != nil:
|
||||
if !errors.Is(err, test.wantErr) {
|
||||
t.Fatalf("select dial strategy error = %v, want %v", err, test.wantErr)
|
||||
}
|
||||
case test.wantErrText != "":
|
||||
if err == nil || err.Error() != test.wantErrText {
|
||||
t.Fatalf("select dial strategy error = %v, want %q", err, test.wantErrText)
|
||||
}
|
||||
case err != nil:
|
||||
t.Fatalf("select dial strategy: %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
if !errors.Is(err, ErrIncompleteWireGuardConfig) {
|
||||
t.Fatalf("expected incomplete WG config error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectMultiplayerDialStrategyEnableWGUsesCompleteLegacyConfig(t *testing.T) {
|
||||
setTestMultiplayerConnectMode(t, MultiplayerConnectEnableWG)
|
||||
|
||||
strategy, err := selectMultiplayerDialStrategy(completeWGClientConfig(false))
|
||||
if err != nil {
|
||||
t.Fatalf("select dial strategy: %v", err)
|
||||
}
|
||||
if strategy != multiplayerDialWireGuard {
|
||||
t.Fatalf("expected WireGuard strategy, got %v", strategy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectMultiplayerDialStrategyDisableWGOverridesEnabledConfig(t *testing.T) {
|
||||
setTestMultiplayerConnectMode(t, MultiplayerConnectDisableWG)
|
||||
|
||||
strategy, err := selectMultiplayerDialStrategy(completeWGClientConfig(true))
|
||||
if err != nil {
|
||||
t.Fatalf("select dial strategy: %v", err)
|
||||
}
|
||||
if strategy != multiplayerDialDirect {
|
||||
t.Fatalf("expected direct strategy, got %v", strategy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectMultiplayerDialStrategyRejectsInvalidMode(t *testing.T) {
|
||||
setTestMultiplayerConnectMode(t, MultiplayerConnectMode(255))
|
||||
|
||||
strategy, err := selectMultiplayerDialStrategy(completeWGClientConfig(true))
|
||||
if strategy != multiplayerDialDirect {
|
||||
t.Fatalf("expected invalid mode to fall back to direct strategy, got %v", strategy)
|
||||
}
|
||||
if err == nil || err.Error() != "invalid multiplayer connect mode 255" {
|
||||
t.Fatalf("expected invalid mode error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -280,6 +280,7 @@ try {
|
||||
$pkg = if ($testDir.StartsWith(".")) { $testDir } else { "./$testDir" }
|
||||
switch -Wildcard ($pkg) {
|
||||
"./server/c2" { continue }
|
||||
"./server/generate" { continue }
|
||||
"./client/*" {
|
||||
$clientTestPkgs += $pkg
|
||||
continue
|
||||
@@ -358,7 +359,6 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
# Includes generation helpers without the sliver_generate compilation tests.
|
||||
foreach ($pkg in $serverUtilTestPkgs) {
|
||||
if (Test-SkipPackage -Package $pkg -Tags "server,$tags") {
|
||||
continue
|
||||
@@ -421,7 +421,7 @@ try {
|
||||
|
||||
Invoke-TestStep -Name "./server/generate" -Action {
|
||||
Invoke-ExternalCommand -FilePath "go" `
|
||||
-Arguments @("test", "-timeout", "6h", "-p", $generateGoP, "-parallel", $generateTestParallel, "-tags=server,$tags,sliver_generate", "./server/generate") `
|
||||
-Arguments @("test", "-timeout", "6h", "-p", $generateGoP, "-parallel", $generateTestParallel, "-tags=server,$tags", "./server/generate") `
|
||||
-Environment @{
|
||||
GOMAXPROCS = $generateGoMaxProcs
|
||||
GOPROXY = "off"
|
||||
@@ -430,9 +430,9 @@ try {
|
||||
} else {
|
||||
Write-Host ""
|
||||
if ($UnitOnly) {
|
||||
Write-Host "Skipping ./server/generate compilation tests (--unit-only)"
|
||||
Write-Host "Skipping ./server/generate tests (--unit-only)"
|
||||
} else {
|
||||
Write-Host "Skipping ./server/generate compilation tests (--skip-generate)"
|
||||
Write-Host "Skipping ./server/generate tests (--skip-generate)"
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
|
||||
+23
-92
@@ -27,70 +27,16 @@ fi
|
||||
|
||||
SKIP_GENERATE=0
|
||||
UNIT_ONLY=0
|
||||
GENERATE_ONLY=0
|
||||
GENERATE_RUN=""
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: ./go-tests.sh [--skip-generate | --unit-only | --generate-only] [--run REGEX]
|
||||
|
||||
--skip-generate Skip implant compilation tests.
|
||||
--unit-only Skip implant compilation and C2 end-to-end tests.
|
||||
--generate-only Run only the server/generate package tests.
|
||||
--run REGEX Select generation tests by Go test name regex; requires --generate-only.
|
||||
--help Show this help.
|
||||
EOF
|
||||
}
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--skip-generate)
|
||||
for arg in "$@"; do
|
||||
if [ "$arg" = "--skip-generate" ]; then
|
||||
SKIP_GENERATE=1
|
||||
;;
|
||||
--unit-only)
|
||||
fi
|
||||
if [ "$arg" = "--unit-only" ]; then
|
||||
UNIT_ONLY=1
|
||||
SKIP_GENERATE=1
|
||||
;;
|
||||
--generate-only)
|
||||
GENERATE_ONLY=1
|
||||
;;
|
||||
--run)
|
||||
if [ "$#" -lt 2 ] || [ -z "$2" ] || [[ "$2" == --* ]]; then
|
||||
echo "--run requires a non-empty regex" >&2
|
||||
exit 2
|
||||
fi
|
||||
GENERATE_RUN="$2"
|
||||
shift
|
||||
;;
|
||||
--run=*)
|
||||
GENERATE_RUN="${1#--run=}"
|
||||
if [ -z "$GENERATE_RUN" ]; then
|
||||
echo "--run requires a non-empty regex" >&2
|
||||
exit 2
|
||||
fi
|
||||
;;
|
||||
--help | -h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$GENERATE_ONLY" -eq 1 ] && [ "$SKIP_GENERATE" -eq 1 ]; then
|
||||
echo "--generate-only cannot be combined with --skip-generate or --unit-only" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [ -n "$GENERATE_RUN" ] && [ "$GENERATE_ONLY" -eq 0 ]; then
|
||||
echo "--run requires --generate-only" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "----------------------------------------------------------------"
|
||||
echo "WARNING: Running unit tests on slow systems can take a LONG time"
|
||||
echo " Recommended to only run on 16+ CPU cores and 32Gb+ RAM"
|
||||
@@ -229,35 +175,8 @@ unpack_server_assets() {
|
||||
"$HOST_GO" run -tags=server,go_sqlite ./server unpack --force
|
||||
}
|
||||
|
||||
run_generate_tests() {
|
||||
# Reclaim the host build cache before generation so long CI runs do not
|
||||
# exhaust the runner's disk while retaining the implant compiler cache.
|
||||
run_test_cmd "clean Go build cache" "$HOST_GO" clean -cache || return 1
|
||||
export GOPROXY=off
|
||||
# Generate tests are memory intensive (garble/c-shared cross-builds). Keep
|
||||
# parallelism conservative by default to avoid OOM kills on smaller systems.
|
||||
local generate_gomaxprocs="${SLIVER_GENERATE_GOMAXPROCS:-2}"
|
||||
local generate_go_p="${SLIVER_GENERATE_GO_P:-1}"
|
||||
local generate_test_parallel="${SLIVER_GENERATE_TEST_PARALLEL:-1}"
|
||||
local generate_test_cmd=(
|
||||
"$HOST_GO" test -timeout 6h -p "$generate_go_p" -parallel "$generate_test_parallel" -tags="server,$TAGS,sliver_generate"
|
||||
)
|
||||
if [ -n "$GENERATE_RUN" ]; then
|
||||
# Keep test selection out of GOFLAGS, which implant compiler children inherit.
|
||||
generate_test_cmd+=(-run "$GENERATE_RUN" -count=1 -v)
|
||||
fi
|
||||
generate_test_cmd+=(./server/generate)
|
||||
run_test_cmd "./server/generate" \
|
||||
env GOMAXPROCS="$generate_gomaxprocs" "${generate_test_cmd[@]}"
|
||||
}
|
||||
|
||||
run_test_cmd "unpack server assets" unpack_server_assets || exit 1
|
||||
|
||||
if [ "$GENERATE_ONLY" -eq 1 ]; then
|
||||
run_generate_tests || exit 1
|
||||
exit 0
|
||||
fi
|
||||
|
||||
TEST_DIRS=()
|
||||
while IFS= read -r test_dir; do
|
||||
TEST_DIRS+=("$test_dir")
|
||||
@@ -272,7 +191,7 @@ E2E_SUPPORT_TEST_PKGS=()
|
||||
for test_dir in "${TEST_DIRS[@]}"; do
|
||||
pkg="./$test_dir"
|
||||
case "$pkg" in
|
||||
./server/c2)
|
||||
./server/c2 | ./server/generate)
|
||||
# handled separately below
|
||||
;;
|
||||
./client/*)
|
||||
@@ -330,7 +249,7 @@ for pkg in "${IMPLANT_TEST_PKGS[@]}"; do
|
||||
run_test_cmd "$pkg" "$HOST_GO" test "$pkg" || exit 1
|
||||
done
|
||||
|
||||
## Server + Util, including generation helpers without compilation tests.
|
||||
## Server + Util
|
||||
for pkg in "${SERVER_UTIL_TEST_PKGS[@]}"; do
|
||||
if should_skip_package "$pkg" "server,$TAGS"; then
|
||||
continue
|
||||
@@ -358,14 +277,26 @@ else
|
||||
run_test_cmd "./server/c2 (e2e dns)" "$HOST_GO" test -tags="server,$TAGS,sliver_e2e" ./server/c2 -run 'TestDNS_' -count=1 || exit 1
|
||||
fi
|
||||
|
||||
## Implant compilation tests opt in with sliver_generate; helper tests run above.
|
||||
## Server generate
|
||||
if [ "$SKIP_GENERATE" -eq 0 ]; then
|
||||
run_generate_tests || exit 1
|
||||
# The package tests above no longer need their Go build cache. Reclaim it
|
||||
# before the cross-platform generation matrix starts so long CI runs do not
|
||||
# exhaust the runner's disk while retaining the implant compiler cache.
|
||||
run_test_cmd "clean Go build cache" "$HOST_GO" clean -cache || exit 1
|
||||
export GOPROXY=off
|
||||
# Generate tests are memory intensive (garble/c-shared cross-builds). Keep
|
||||
# parallelism conservative by default to avoid OOM kills on smaller systems.
|
||||
GENERATE_GOMAXPROCS="${SLIVER_GENERATE_GOMAXPROCS:-2}"
|
||||
GENERATE_GO_P="${SLIVER_GENERATE_GO_P:-1}"
|
||||
GENERATE_TEST_PARALLEL="${SLIVER_GENERATE_TEST_PARALLEL:-1}"
|
||||
run_test_cmd "./server/generate" \
|
||||
env GOMAXPROCS="$GENERATE_GOMAXPROCS" \
|
||||
"$HOST_GO" test -timeout 6h -p "$GENERATE_GO_P" -parallel "$GENERATE_TEST_PARALLEL" -tags="server,$TAGS" ./server/generate || exit 1
|
||||
else
|
||||
echo
|
||||
if [ "$UNIT_ONLY" -eq 1 ]; then
|
||||
echo "Skipping ./server/generate compilation tests (--unit-only)"
|
||||
echo "Skipping ./server/generate tests (--unit-only)"
|
||||
else
|
||||
echo "Skipping ./server/generate compilation tests (--skip-generate)"
|
||||
echo "Skipping ./server/generate tests (--skip-generate)"
|
||||
fi
|
||||
fi
|
||||
@@ -10,7 +10,7 @@ replace github.com/reeflective/console => github.com/moloch--/console v0.0.0-202
|
||||
|
||||
require (
|
||||
charm.land/bubbles/v2 v2.0.0
|
||||
charm.land/bubbletea/v2 v2.1.0
|
||||
charm.land/bubbletea/v2 v2.0.2
|
||||
charm.land/glamour/v2 v2.0.1
|
||||
charm.land/huh/v2 v2.0.3
|
||||
charm.land/lipgloss/v2 v2.0.6
|
||||
@@ -24,29 +24,27 @@ require (
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/carapace-sh/carapace v1.15.3
|
||||
github.com/charmbracelet/colorprofile v0.4.3
|
||||
github.com/charmbracelet/x/ansi v0.11.9
|
||||
github.com/charmbracelet/x/ansi v0.11.8
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260109001716-2fbdffcb221f
|
||||
github.com/cheggaaa/pb/v3 v3.1.7
|
||||
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d
|
||||
github.com/chromedp/chromedp v0.14.1
|
||||
github.com/ebitengine/purego v0.11.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-sql-driver/mysql v1.9.3
|
||||
github.com/gofrs/uuid v4.4.0+incompatible
|
||||
github.com/golang/protobuf v1.5.4
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0
|
||||
github.com/hashicorp/yamux v0.1.2
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
github.com/jedib0t/go-pretty/v6 v6.8.3
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
|
||||
github.com/kbinani/screenshot v0.0.0-20250624051815-089614a94018
|
||||
github.com/klauspost/compress v1.18.7
|
||||
github.com/klauspost/compress v1.18.4
|
||||
github.com/lesnuages/go-winio v0.4.19
|
||||
github.com/lesnuages/snitch v0.6.0
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1
|
||||
github.com/mark3labs/mcp-go v0.45.0
|
||||
github.com/mattn/go-sqlite3 v1.14.52
|
||||
github.com/mattn/go-sqlite3 v1.14.50
|
||||
github.com/miekg/dns v1.1.70
|
||||
github.com/moloch--/asciicast v0.1.1
|
||||
github.com/moloch--/go-keystone v0.0.2
|
||||
@@ -75,7 +73,7 @@ require (
|
||||
golang.org/x/crypto v0.55.0
|
||||
golang.org/x/mod v0.40.0
|
||||
golang.org/x/net v0.58.0
|
||||
golang.org/x/sys v0.49.0
|
||||
golang.org/x/sys v0.47.0
|
||||
golang.org/x/term v0.45.0
|
||||
golang.org/x/text v0.41.0
|
||||
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb
|
||||
@@ -152,7 +150,7 @@ require (
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5 // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||
@@ -189,11 +187,12 @@ require (
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
|
||||
github.com/go-lark/lark v1.16.0 // indirect
|
||||
github.com/go-logr/logr v1.4.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-redis/redis/v8 v8.11.6-0.20220405070650-99c79f7041fc // indirect
|
||||
github.com/go-sql-driver/mysql v1.9.3 // indirect
|
||||
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible // indirect
|
||||
github.com/gobwas/httphead v0.1.0 // indirect
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
@@ -219,6 +218,7 @@ require (
|
||||
github.com/invopop/jsonschema v0.13.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.10.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jcmturner/gofork v1.7.6 // indirect
|
||||
github.com/jezek/xgb v1.1.1 // indirect
|
||||
@@ -238,7 +238,7 @@ require (
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.30 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.24 // indirect
|
||||
github.com/mdlayher/netlink v1.8.0 // indirect
|
||||
github.com/mdlayher/socket v0.5.1 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||
@@ -286,7 +286,7 @@ require (
|
||||
github.com/utahta/go-linenotify v0.5.0 // indirect
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/xo/terminfo v1.0.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
github.com/yuin/goldmark v1.8.5 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.5 // indirect
|
||||
@@ -295,11 +295,11 @@ require (
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
|
||||
go.opentelemetry.io/otel v1.45.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.45.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.45.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.45.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.45.0 // indirect
|
||||
go.opentelemetry.io/otel v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
||||
|
||||
@@ -6,8 +6,8 @@ cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs=
|
||||
cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||
charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
|
||||
charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
|
||||
charm.land/bubbletea/v2 v2.1.0 h1:A9fj9h5LFrvQhQHFNggp8K8rvY6CUfgn9dMbb6p2HIw=
|
||||
charm.land/bubbletea/v2 v2.1.0/go.mod h1:xWHeyxIX9/wiFU2kgPW04ojOZyTzrWf+8lGjUCaftiU=
|
||||
charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
|
||||
charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
|
||||
charm.land/glamour/v2 v2.0.1 h1:xl+r00A4aJWU0z8fgwKd9fQQ4rsphqGUzuEiXZP5n+c=
|
||||
charm.land/glamour/v2 v2.0.1/go.mod h1:jo9z8XqVKPeEFMVdvCRLGk++RyJ3CdUwgNr7EvXLw3k=
|
||||
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
|
||||
@@ -179,10 +179,10 @@ github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex
|
||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
||||
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4 h1:hISLzUbwcvJbGoNR6A3L/TpguRRnACBmZOee8LcHLJw=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20261008173134-6b8d4baf91b4/go.mod h1:aJDVk+WOVM1K04rdc/71PMPoUGfjxjhvXfP3Pjz1Zio=
|
||||
github.com/charmbracelet/x/ansi v0.11.9 h1:kIrqLBxtTVbLyyHO2Evoi93EjQo1mylWmpNEsfPxXAM=
|
||||
github.com/charmbracelet/x/ansi v0.11.9/go.mod h1:DG/hpOnaI2QJ/KMMqLL2h0KVhluuJLazf8973P+Vy5w=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro=
|
||||
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
|
||||
github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
|
||||
github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
|
||||
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
||||
@@ -316,8 +316,8 @@ github.com/go-lark/lark v1.16.0 h1:U6BwkLM9wrZedSM7cIiMofganr8PCvJN+M75w2lf2Gg=
|
||||
github.com/go-lark/lark v1.16.0/go.mod h1:6ltbSztPZRT6IaO9ZIQyVaY5pVp/KeMizDYtfZkU+vM=
|
||||
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
||||
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
@@ -484,8 +484,8 @@ github.com/kevinburke/twilio-go v0.0.0-20260522200016-74f727d55757 h1:dQyNAHiOY8
|
||||
github.com/kevinburke/twilio-go v0.0.0-20260522200016-74f727d55757/go.mod h1:Z7bqFOTtIqCCIO+uPtod7Fp3gPMR8aTCcp5Pe8D+3fE=
|
||||
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.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw=
|
||||
github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
@@ -528,10 +528,10 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
|
||||
github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
|
||||
github.com/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U=
|
||||
github.com/mattn/go-sqlite3 v1.14.52/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
|
||||
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-sqlite3 v1.14.50 h1:dmdFvo1XG4MPzA4IkAmE9upVz/Nj31uRoM5+jC8hYbY=
|
||||
github.com/mattn/go-sqlite3 v1.14.50/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
|
||||
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
|
||||
github.com/mdlayher/netlink v1.8.0 h1:e7XNIYJKD7hUct3Px04RuIGJbBxy1/c4nX7D5YyvvlM=
|
||||
@@ -552,6 +552,8 @@ github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||
github.com/moloch--/asciicast v0.1.1 h1:btwwniCK9br3ys33XZ71IG/M3ZujSSCcBzsH/sieiXg=
|
||||
github.com/moloch--/asciicast v0.1.1/go.mod h1:OckO16UDLgxVLclrCnbocL1ix15Br/8Xv/caBoYq98o=
|
||||
github.com/moloch--/console v0.0.0-20260129035459-883dcb25c701 h1:6caCgDZpovjvv+eclRytFDf0GpVEnjDmoJRHpfkb42E=
|
||||
github.com/moloch--/console v0.0.0-20260129035459-883dcb25c701/go.mod h1:UdqyqqCSnPu3Zc8Q3kpDszrq1hcyBHsG17hlWlRJmWI=
|
||||
github.com/moloch--/console v0.0.0-20261002031128-9c7d5fba75f5 h1:BwamAdS8GG5tsxKisLphOOC5RVmTcy8Ixf+oGyhh/Jc=
|
||||
github.com/moloch--/console v0.0.0-20261002031128-9c7d5fba75f5/go.mod h1:UdqyqqCSnPu3Zc8Q3kpDszrq1hcyBHsG17hlWlRJmWI=
|
||||
github.com/moloch--/go-keystone v0.0.2 h1:xThbYs45CSzoDUz5kBZxAyTPIpK3t5M7NB1x+NTTRL8=
|
||||
@@ -761,8 +763,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ=
|
||||
github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0=
|
||||
github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY=
|
||||
github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yiya1989/sshkrb5 v0.0.1 h1:oAKpXLBeZExjYDV9eTL2KypmXU+5oPrJtqDuR5JbWic=
|
||||
github.com/yiya1989/sshkrb5 v0.0.1/go.mod h1:r1bHcNa22x8rrg/Kj/cDF5bX6UtZrHiQgRFvj3Mnpl8=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
@@ -791,20 +793,20 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0/go.mod h1:D7J12YRapIekYyPWgGPlA/23pRmpSEZC5xJC/TTLI9U=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
|
||||
go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
|
||||
go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A=
|
||||
go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
|
||||
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
|
||||
go.opentelemetry.io/otel/metric/x v0.67.0 h1:PcicCNZFkZ4bXfSooXdo3WN7RBOVOtjVdo1wD358Uns=
|
||||
go.opentelemetry.io/otel/metric/x v0.67.0/go.mod h1:FBjCWZe6wgcqxcMtjdGiClDKXb2YxxXii0CXftE4QtI=
|
||||
go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw=
|
||||
go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA=
|
||||
go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
|
||||
go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA=
|
||||
go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
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=
|
||||
@@ -926,8 +928,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
|
||||
@@ -25,17 +25,10 @@ var (
|
||||
SliverName = `{{.Name}}`
|
||||
)
|
||||
|
||||
// Message carries canaries in struct tags, which remain plaintext under Garble.
|
||||
// Message - Fake message for embedding canaries
|
||||
type Message struct {
|
||||
Command string `c2:"[[GenerateCanary]]"`
|
||||
// {{if .Config.IncludeMTLS}}
|
||||
MTLS string `c2:"[[GenerateCanary]]"`
|
||||
// {{end}}
|
||||
// {{if .Config.IncludeWG}}
|
||||
WireGuard string `c2:"[[GenerateCanary]]"`
|
||||
// {{end}}
|
||||
}
|
||||
|
||||
// Access a field at runtime to retain the type metadata and its canary tags.
|
||||
// A discarded TypeOf call alone is optimized away, removing the canaries.
|
||||
var _ = reflect.TypeOf(Message{}).Field(0)
|
||||
// never obfuscate the Message type
|
||||
var _ = reflect.TypeOf(Message{})
|
||||
@@ -19,8 +19,6 @@ package extension
|
||||
*/
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"io/fs"
|
||||
"testing"
|
||||
)
|
||||
@@ -35,11 +33,7 @@ func TestBasicMemFSOpenFile(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stat, err := fi.Stat()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stat.Name() != "memfs/test.1" {
|
||||
if stat, _ := fi.Stat(); stat.Name() != "memfs/test.1" {
|
||||
t.Fatalf("expected memfs/test.1, got %s", stat.Name())
|
||||
}
|
||||
defer fi.Close()
|
||||
@@ -64,24 +58,20 @@ func TestMemFSOpenRoot(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantEntries := map[string]bool{"memfs/test.1": false, "memfs/test.2": false, "memfs/a": true}
|
||||
if len(entries) != len(wantEntries) {
|
||||
t.Fatalf("expected %d entries, got %d", len(wantEntries), len(entries))
|
||||
}
|
||||
for _, entry := range entries {
|
||||
wantDir, ok := wantEntries[entry.Name()]
|
||||
if !ok || entry.IsDir() != wantDir {
|
||||
t.Fatalf("unexpected entry %q (directory=%v)", entry.Name(), entry.IsDir())
|
||||
if len(entries) != 3 {
|
||||
for entry := range entries {
|
||||
t.Logf("entry: %s", entries[entry].Name())
|
||||
}
|
||||
delete(wantEntries, entry.Name())
|
||||
t.Fatalf("expected 3 entries, got %d", len(entries))
|
||||
}
|
||||
|
||||
di, err := dirNode.Stat()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if di.Name() != "memfs" || !di.IsDir() {
|
||||
t.Fatalf("expected memfs root directory, got %q (directory=%v)", di.Name(), di.IsDir())
|
||||
t.Logf("name: %s, is dir: %v", di.Name(), di.IsDir())
|
||||
for _, entry := range entries {
|
||||
t.Logf("entry: %s", entry.Name())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +80,7 @@ func TestBasicMemFSOpenDir(t *testing.T) {
|
||||
"test/foo.1": {0x00},
|
||||
"test/foo": {0x00},
|
||||
"testing/foo": {0x00},
|
||||
"a/b/c/d/e/f/test/a.txt": {0x31, 0x00, 0x7f},
|
||||
"a/b/c/d/e/f/test/a.txt": {0x00},
|
||||
}}
|
||||
|
||||
// Test Open File
|
||||
@@ -101,11 +91,7 @@ func TestBasicMemFSOpenDir(t *testing.T) {
|
||||
if f == nil {
|
||||
t.Fatal("expected file, got nil and no error")
|
||||
}
|
||||
stat, err := f.Stat()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stat.Name() != "memfs/test" || !stat.IsDir() {
|
||||
if stat, _ := f.Stat(); stat.Name() != "memfs/test" || !stat.IsDir() {
|
||||
t.Fatalf("expected 'memfs/test' dir, got '%s'", stat.Name())
|
||||
}
|
||||
defer f.Close()
|
||||
@@ -114,16 +100,7 @@ func TestBasicMemFSOpenDir(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if err := dir.Close(); err != nil {
|
||||
t.Errorf("close directory: %v", err)
|
||||
}
|
||||
}()
|
||||
stat, err = dir.Stat()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stat.Name() != "memfs/a/b/c/d/e/f/test" || !stat.IsDir() {
|
||||
if stat, _ := dir.Stat(); stat.Name() != "memfs/a/b/c/d/e/f/test" || !stat.IsDir() {
|
||||
t.Fatalf("expected 'memfs/a/b/c/d/e/f/test' dir, got '%s'", stat.Name())
|
||||
}
|
||||
|
||||
@@ -131,17 +108,19 @@ func TestBasicMemFSOpenDir(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if err := f.Close(); err != nil {
|
||||
t.Errorf("close file: %v", err)
|
||||
}
|
||||
}()
|
||||
assertMemFSFileContents(t, f, []byte{0x31, 0x00, 0x7f})
|
||||
data := make([]byte, 1)
|
||||
_, err = f.Read(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if data[0] != 0x00 {
|
||||
t.Fatalf("expected 0x00, got %x", data[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemFSReadFileData(t *testing.T) {
|
||||
wasmFS := WasmMemoryFS{memFS: map[string][]byte{
|
||||
"test/foo.1": {0x41, 0x00, 0xff, 0x42},
|
||||
"test/foo.1": {0x00},
|
||||
}}
|
||||
|
||||
// Test Open File
|
||||
@@ -149,31 +128,19 @@ func TestMemFSReadFileData(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stat, err := fi.Stat()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stat.Name() != "memfs/test/foo.1" {
|
||||
if stat, _ := fi.Stat(); stat.Name() != "memfs/test/foo.1" {
|
||||
t.Fatalf("expected memfs/test/foo.1, got %s", stat.Name())
|
||||
}
|
||||
defer fi.Close()
|
||||
|
||||
assertMemFSFileContents(t, fi, []byte{0x41, 0x00, 0xff, 0x42})
|
||||
}
|
||||
// Read file
|
||||
data := make([]byte, 1)
|
||||
_, err = fi.Read(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if data[0] != 0x00 {
|
||||
t.Fatalf("expected 0x00, got %x", data[0])
|
||||
}
|
||||
|
||||
func assertMemFSFileContents(t *testing.T, file fs.File, want []byte) {
|
||||
t.Helper()
|
||||
// Read in chunks to verify byte counts and advancement of the file offset.
|
||||
for offset := 0; offset < len(want); {
|
||||
end := min(offset+2, len(want))
|
||||
data := make([]byte, end-offset)
|
||||
n, err := file.Read(data)
|
||||
if err != nil || n != len(data) || !bytes.Equal(data, want[offset:end]) {
|
||||
t.Fatalf("Read at %d = (%x, %d, %v), want (%x, %d, nil)", offset, data, n, err, want[offset:end], len(data))
|
||||
}
|
||||
offset = end
|
||||
}
|
||||
if n, err := file.Read(make([]byte, 1)); n != 0 || err != io.EOF {
|
||||
t.Fatalf("Read after file contents = (%d, %v), want (0, EOF)", n, err)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package tunnel_handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
@@ -11,6 +10,7 @@ import (
|
||||
"log"
|
||||
// {{end}}
|
||||
|
||||
"github.com/bishopfox/sliver/implant/sliver/encoders"
|
||||
"github.com/bishopfox/sliver/implant/sliver/extension"
|
||||
"github.com/bishopfox/sliver/implant/sliver/transports"
|
||||
"github.com/bishopfox/sliver/protobuf/commonpb"
|
||||
@@ -39,7 +39,7 @@ func ExecWasmExtensionHandler(envelope *pb.Envelope, connection *transports.Conn
|
||||
return
|
||||
}
|
||||
|
||||
wasmBin, err := decodeWasmGzip(extReq.WasmGz)
|
||||
wasmBin, err := encoders.Gzip.Decode(extReq.WasmGz)
|
||||
if err != nil {
|
||||
// {{if .Config.Debug}}
|
||||
log.Printf("error decoding Wasm extension: %v", err)
|
||||
@@ -76,19 +76,6 @@ func ExecWasmExtensionHandler(envelope *pb.Envelope, connection *transports.Conn
|
||||
}
|
||||
}
|
||||
|
||||
func decodeWasmGzip(data []byte) ([]byte, error) {
|
||||
reader, err := gzip.NewReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
_, err = buf.ReadFrom(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func runNonInteractive(req *pb.ExecWasmExtensionReq, wasm *extension.WasmExtension) ([]byte, error) {
|
||||
// {{if .Config.Debug}}
|
||||
log.Printf("Executing non-interactive wasm extension")
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
package tunnel_handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDecodeWasmGzip(t *testing.T) {
|
||||
// An empty WebAssembly v1 module has the magic and version headers.
|
||||
wasm := []byte{0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00}
|
||||
var compressed bytes.Buffer
|
||||
writer := gzip.NewWriter(&compressed)
|
||||
if _, err := writer.Write(wasm); err != nil {
|
||||
t.Fatalf("compress WASM: %v", err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("finish gzip stream: %v", err)
|
||||
}
|
||||
|
||||
got, err := decodeWasmGzip(compressed.Bytes())
|
||||
if err != nil {
|
||||
t.Fatalf("decode valid WASM payload: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, wasm) {
|
||||
t.Fatalf("decoded payload = %x, want %x", got, wasm)
|
||||
}
|
||||
|
||||
if _, err := decodeWasmGzip([]byte("not a gzip stream")); err == nil {
|
||||
t.Fatal("expected malformed gzip to fail")
|
||||
}
|
||||
}
|
||||
@@ -189,7 +189,7 @@ func ReadEnvelope(r io.Reader) (*pb.Envelope, error) {
|
||||
rawSigBuf := make([]byte, cryptography.RawSigSize)
|
||||
dataLengthBuf := make([]byte, 4) // Size of uint32
|
||||
if len(rawSigBuf) == 0 || len(dataLengthBuf) == 0 || r == nil {
|
||||
panic("invalid envelope reader")
|
||||
panic("[[GenerateCanary]]")
|
||||
}
|
||||
|
||||
n, err := io.ReadFull(r, rawSigBuf)
|
||||
|
||||
@@ -182,7 +182,7 @@ func ReadEnvelope(connection net.Conn) (*pb.Envelope, error) {
|
||||
rawSigBuf := make([]byte, cryptography.RawSigSize)
|
||||
dataLengthBuf := make([]byte, 4) // Size of uint32
|
||||
if len(rawSigBuf) == 0 || len(dataLengthBuf) == 0 || connection == nil {
|
||||
panic("invalid envelope connection")
|
||||
panic("[[GenerateCanary]]")
|
||||
}
|
||||
|
||||
n, err := io.ReadFull(connection, rawSigBuf)
|
||||
|
||||
+175
-450
@@ -24,6 +24,7 @@ import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
@@ -34,6 +35,7 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -227,8 +229,11 @@ func setupGo(appDir string) error {
|
||||
|
||||
// SetupGoPath - Extracts dependencies to goPathSrc
|
||||
func SetupGoPath(goPathSrc string, includeDNS bool) error {
|
||||
if err := os.MkdirAll(goPathSrc, 0700); err != nil {
|
||||
return fmt.Errorf("create implant source directory %s: %w", goPathSrc, err)
|
||||
|
||||
// GOPATH setup
|
||||
if _, err := os.Stat(goPathSrc); os.IsNotExist(err) {
|
||||
setupLog.Infof("Creating GOPATH directory: %s", goPathSrc)
|
||||
os.MkdirAll(goPathSrc, 0700)
|
||||
}
|
||||
|
||||
// Sliver PB
|
||||
@@ -247,27 +252,17 @@ func SetupGoPath(goPathSrc string, includeDNS bool) error {
|
||||
setupLog.Info("Static asset not found: capabilities.go")
|
||||
return err
|
||||
}
|
||||
sliverpbGoSrc, err = xorPBRawBytes(sliverpbGoSrc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("obfuscate sliverpb descriptor: %w", err)
|
||||
}
|
||||
sliverpbGoSrc, err = rewriteProtobufStructTags(sliverpbGoSrc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("obfuscate sliverpb tags: %w", err)
|
||||
}
|
||||
sliverpbGoSrc = xorPBRawBytes(sliverpbGoSrc)
|
||||
sliverpbGoSrc = stripSliverpb(sliverpbGoSrc)
|
||||
sliverpbDir := filepath.Join(goPathSrc, "protobuf", "sliverpb")
|
||||
setupLog.Infof("Creating sliverpb directory: %s", sliverpbDir)
|
||||
if err := os.MkdirAll(sliverpbDir, 0700); err != nil {
|
||||
return fmt.Errorf("create sliverpb directory: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sliverpbDir, "sliver.pb.go"), sliverpbGoSrc, 0600); err != nil {
|
||||
return fmt.Errorf("write sliver.pb.go: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sliverpbDir, "constants.go"), sliverpbConstSrc, 0600); err != nil {
|
||||
return fmt.Errorf("write sliverpb constants.go: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sliverpbDir, "capabilities.go"), sliverpbCapabilitiesSrc, 0600); err != nil {
|
||||
return fmt.Errorf("write sliverpb capabilities.go: %w", err)
|
||||
os.MkdirAll(sliverpbDir, 0700)
|
||||
os.WriteFile(filepath.Join(sliverpbDir, "sliver.pb.go"), sliverpbGoSrc, 0600)
|
||||
os.WriteFile(filepath.Join(sliverpbDir, "constants.go"), sliverpbConstSrc, 0600)
|
||||
err = os.WriteFile(filepath.Join(sliverpbDir, "capabilities.go"), sliverpbCapabilitiesSrc, 0600)
|
||||
if err != nil {
|
||||
setupLog.Errorf("Failed to write capabilities.go: %s", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Common PB
|
||||
@@ -276,21 +271,10 @@ func SetupGoPath(goPathSrc string, includeDNS bool) error {
|
||||
setupLog.Info("Static asset not found: common.pb.go")
|
||||
return err
|
||||
}
|
||||
commonpbSrc, err = xorPBRawBytes(commonpbSrc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("obfuscate commonpb descriptor: %w", err)
|
||||
}
|
||||
commonpbSrc, err = rewriteProtobufStructTags(commonpbSrc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("obfuscate commonpb tags: %w", err)
|
||||
}
|
||||
commonpbSrc = xorPBRawBytes(commonpbSrc)
|
||||
commonpbDir := filepath.Join(goPathSrc, "protobuf", "commonpb")
|
||||
if err := os.MkdirAll(commonpbDir, 0700); err != nil {
|
||||
return fmt.Errorf("create commonpb directory: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(commonpbDir, "common.pb.go"), commonpbSrc, 0600); err != nil {
|
||||
return fmt.Errorf("write common.pb.go: %w", err)
|
||||
}
|
||||
os.MkdirAll(commonpbDir, 0700)
|
||||
os.WriteFile(filepath.Join(commonpbDir, "common.pb.go"), commonpbSrc, 0600)
|
||||
|
||||
// DNS PB
|
||||
if includeDNS {
|
||||
@@ -299,242 +283,34 @@ func SetupGoPath(goPathSrc string, includeDNS bool) error {
|
||||
setupLog.Info("Static asset not found: dns.pb.go")
|
||||
return err
|
||||
}
|
||||
dnspbSrc, err = xorPBRawBytes(dnspbSrc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("obfuscate dnspb descriptor: %w", err)
|
||||
}
|
||||
dnspbSrc, err = rewriteProtobufStructTags(dnspbSrc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("obfuscate dnspb tags: %w", err)
|
||||
}
|
||||
dnspbSrc = xorPBRawBytes(dnspbSrc)
|
||||
dnspbDir := filepath.Join(goPathSrc, "protobuf", "dnspb")
|
||||
if err := os.MkdirAll(dnspbDir, 0700); err != nil {
|
||||
return fmt.Errorf("create dnspb directory: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dnspbDir, "dns.pb.go"), dnspbSrc, 0600); err != nil {
|
||||
return fmt.Errorf("write dns.pb.go: %w", err)
|
||||
}
|
||||
os.MkdirAll(dnspbDir, 0700)
|
||||
os.WriteFile(filepath.Join(dnspbDir, "dns.pb.go"), dnspbSrc, 0600)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type structTagPart struct {
|
||||
key string
|
||||
value string
|
||||
}
|
||||
|
||||
// parseStructTagParts parses the key/value pairs in a Go struct tag without
|
||||
// assuming an order or a particular set of protobuf options.
|
||||
func parseStructTagParts(tag string) ([]structTagPart, error) {
|
||||
var parts []structTagPart
|
||||
for tag != "" {
|
||||
tag = strings.TrimLeft(tag, " ")
|
||||
if tag == "" {
|
||||
break
|
||||
}
|
||||
colon := strings.IndexByte(tag, ':')
|
||||
if colon <= 0 || strings.ContainsAny(tag[:colon], " \t\n\r\"`") {
|
||||
return nil, fmt.Errorf("invalid struct tag key near %q", tag)
|
||||
}
|
||||
key := tag[:colon]
|
||||
tag = tag[colon+1:]
|
||||
if !strings.HasPrefix(tag, `"`) {
|
||||
return nil, fmt.Errorf("missing quoted value for struct tag %q", key)
|
||||
}
|
||||
end := 1
|
||||
for end < len(tag) {
|
||||
if tag[end] == '\\' {
|
||||
end += 2
|
||||
continue
|
||||
}
|
||||
if tag[end] == '"' {
|
||||
break
|
||||
}
|
||||
end++
|
||||
}
|
||||
if end >= len(tag) {
|
||||
return nil, fmt.Errorf("unterminated struct tag value for %q", key)
|
||||
}
|
||||
value, err := strconv.Unquote(tag[:end+1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unquote struct tag %q: %w", key, err)
|
||||
}
|
||||
parts = append(parts, structTagPart{key: key, value: value})
|
||||
tag = tag[end+1:]
|
||||
if tag != "" && tag[0] != ' ' {
|
||||
return nil, fmt.Errorf("missing separator after struct tag %q", key)
|
||||
func stripSliverpb(src []byte) []byte {
|
||||
out := src
|
||||
re := regexp.MustCompile(`protobuf:"[a-z]+,\d+,[a-z]+,name=(?P<FieldName1>[a-zA-Z0-9]+),proto3(,enum=(?P<EnumName>[a-zA-Z\.]+))?" json:"(?P<FiledName2>[a-zA-Z0-9]+),[a-z]+"`)
|
||||
found := re.FindAllSubmatch(src, -1)
|
||||
for _, x := range found {
|
||||
line := x[0] // line that matched
|
||||
typeName := x[1] // first named capturing group (FieldName1)
|
||||
enumName := x[3]
|
||||
if string(enumName) != "" {
|
||||
newEnumName := pseudoRandStringRunes(len(enumName))
|
||||
newEnumLine := bytes.ReplaceAll(line, enumName, []byte(newEnumName))
|
||||
out = bytes.ReplaceAll(out, line, []byte(newEnumLine))
|
||||
line = newEnumLine
|
||||
}
|
||||
// we don't care about FieldName2 because its value is the same as FieldName1
|
||||
newItem := pseudoRandStringRunes(len(typeName))
|
||||
newLine := bytes.ReplaceAll(line, typeName, []byte(newItem))
|
||||
out = bytes.ReplaceAll(out, line, []byte(newLine))
|
||||
}
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
func renderStructTagParts(parts []structTagPart) string {
|
||||
var out strings.Builder
|
||||
for i, part := range parts {
|
||||
if i != 0 {
|
||||
out.WriteByte(' ')
|
||||
}
|
||||
out.WriteString(part.key)
|
||||
out.WriteByte(':')
|
||||
out.WriteString(strconv.Quote(part.value))
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func randomDifferentName(original string) string {
|
||||
for {
|
||||
candidate := pseudoRandStringRunes(len(original))
|
||||
if candidate != original {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func rewriteProtobufTagValue(value string) (string, string, string, error) {
|
||||
options := strings.Split(value, ",")
|
||||
nameIndex, enumIndex := -1, -1
|
||||
fieldNumberFound := false
|
||||
for i, option := range options {
|
||||
if _, err := strconv.ParseUint(option, 10, 32); err == nil {
|
||||
fieldNumberFound = true
|
||||
}
|
||||
if strings.HasPrefix(option, "name=") {
|
||||
if nameIndex >= 0 {
|
||||
return "", "", "", fmt.Errorf("duplicate protobuf field name")
|
||||
}
|
||||
nameIndex = i
|
||||
}
|
||||
if strings.HasPrefix(option, "enum=") {
|
||||
enumIndex = i
|
||||
}
|
||||
}
|
||||
if !fieldNumberFound || nameIndex < 0 || len(options[nameIndex]) == len("name=") {
|
||||
return "", "", "", fmt.Errorf("protobuf struct tag lacks a field number or name: %q", value)
|
||||
}
|
||||
originalName := strings.TrimPrefix(options[nameIndex], "name=")
|
||||
newName := randomDifferentName(originalName)
|
||||
options[nameIndex] = "name=" + newName
|
||||
if enumIndex >= 0 {
|
||||
enumName := strings.TrimPrefix(options[enumIndex], "enum=")
|
||||
if enumName == "" {
|
||||
return "", "", "", fmt.Errorf("empty protobuf enum name")
|
||||
}
|
||||
options[enumIndex] = "enum=" + randomDifferentName(enumName)
|
||||
}
|
||||
return strings.Join(options, ","), originalName, newName, nil
|
||||
}
|
||||
|
||||
func rewriteJSONTagValue(value, originalName, newName string) (string, error) {
|
||||
options := strings.SplitN(value, ",", 2)
|
||||
if options[0] == "" || options[0] == "-" {
|
||||
return "", fmt.Errorf("unsupported JSON field name %q", options[0])
|
||||
}
|
||||
if options[0] == originalName {
|
||||
options[0] = newName
|
||||
} else {
|
||||
options[0] = randomDifferentName(options[0])
|
||||
}
|
||||
return strings.Join(options, ","), nil
|
||||
}
|
||||
|
||||
func rewritePBStructTag(raw string, cache map[string]string) (string, bool, error) {
|
||||
if rewritten, ok := cache[raw]; ok {
|
||||
return rewritten, true, nil
|
||||
}
|
||||
parts, err := parseStructTagParts(raw)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
protobufIndex, jsonIndex := -1, -1
|
||||
for i, part := range parts {
|
||||
switch part.key {
|
||||
case "protobuf":
|
||||
if protobufIndex >= 0 {
|
||||
return "", false, fmt.Errorf("duplicate protobuf struct tag")
|
||||
}
|
||||
protobufIndex = i
|
||||
case "json":
|
||||
if jsonIndex >= 0 {
|
||||
return "", false, fmt.Errorf("duplicate JSON struct tag")
|
||||
}
|
||||
jsonIndex = i
|
||||
}
|
||||
}
|
||||
if protobufIndex < 0 {
|
||||
return raw, false, nil
|
||||
}
|
||||
if jsonIndex < 0 {
|
||||
return "", false, fmt.Errorf("protobuf struct tag is missing a JSON tag")
|
||||
}
|
||||
protobufValue, originalName, newName, err := rewriteProtobufTagValue(parts[protobufIndex].value)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
parts[protobufIndex].value = protobufValue
|
||||
parts[jsonIndex].value, err = rewriteJSONTagValue(parts[jsonIndex].value, originalName, newName)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
rewritten := renderStructTagParts(parts)
|
||||
cache[raw] = rewritten
|
||||
return rewritten, true, nil
|
||||
}
|
||||
|
||||
// rewriteProtobufStructTags rewrites generated protobuf struct tags after parsing their
|
||||
// syntax, including packed fields and future option ordering changes.
|
||||
func rewriteProtobufStructTags(src []byte) ([]byte, error) {
|
||||
fset := token.NewFileSet()
|
||||
fileAst, err := parser.ParseFile(fset, "", src, parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse protobuf source: %w", err)
|
||||
}
|
||||
cache := make(map[string]string)
|
||||
rewritten := 0
|
||||
var rewriteErr error
|
||||
ast.Inspect(fileAst, func(node ast.Node) bool {
|
||||
if rewriteErr != nil {
|
||||
return false
|
||||
}
|
||||
structType, ok := node.(*ast.StructType)
|
||||
if !ok || structType.Fields == nil {
|
||||
return true
|
||||
}
|
||||
for _, field := range structType.Fields.List {
|
||||
if field.Tag == nil {
|
||||
continue
|
||||
}
|
||||
raw, err := strconv.Unquote(field.Tag.Value)
|
||||
if err != nil {
|
||||
rewriteErr = fmt.Errorf("parse struct tag at %s: %w", fset.Position(field.Pos()), err)
|
||||
return false
|
||||
}
|
||||
newTag, changed, err := rewritePBStructTag(raw, cache)
|
||||
if err != nil {
|
||||
rewriteErr = fmt.Errorf("rewrite struct tag at %s: %w", fset.Position(field.Pos()), err)
|
||||
return false
|
||||
}
|
||||
if changed {
|
||||
rewritten++
|
||||
if strings.ContainsRune(newTag, '`') {
|
||||
field.Tag.Value = strconv.Quote(newTag)
|
||||
} else {
|
||||
field.Tag.Value = "`" + newTag + "`"
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if rewriteErr != nil {
|
||||
return nil, rewriteErr
|
||||
}
|
||||
if rewritten == 0 {
|
||||
return nil, fmt.Errorf("no protobuf struct tags found in source")
|
||||
}
|
||||
var out bytes.Buffer
|
||||
if err := printer.Fprint(&out, fset, fileAst); err != nil {
|
||||
return nil, fmt.Errorf("print rewritten protobuf source: %w", err)
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
return out
|
||||
}
|
||||
|
||||
// UntarSkipTopLevel - Untar a tar file, skipping the top level directory
|
||||
@@ -614,225 +390,174 @@ func unzipSkipTopLevel(dst string, z *zip.Reader) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func descriptorStringLiterals(expr ast.Expr) ([]*ast.BasicLit, error) {
|
||||
var literals []*ast.BasicLit
|
||||
var walk func(ast.Expr) error
|
||||
walk = func(expr ast.Expr) error {
|
||||
switch node := expr.(type) {
|
||||
case *ast.BasicLit:
|
||||
if node.Kind != token.STRING {
|
||||
return fmt.Errorf("descriptor expression contains a non-string literal")
|
||||
}
|
||||
if _, err := strconv.Unquote(node.Value); err != nil {
|
||||
return fmt.Errorf("decode descriptor string literal: %w", err)
|
||||
}
|
||||
literals = append(literals, node)
|
||||
return nil
|
||||
case *ast.BinaryExpr:
|
||||
if node.Op != token.ADD {
|
||||
return fmt.Errorf("descriptor expression uses operator %s", node.Op)
|
||||
}
|
||||
if err := walk(node.X); err != nil {
|
||||
return err
|
||||
}
|
||||
return walk(node.Y)
|
||||
case *ast.ParenExpr:
|
||||
return walk(node.X)
|
||||
default:
|
||||
return fmt.Errorf("unsupported descriptor expression %T", expr)
|
||||
}
|
||||
}
|
||||
if err := walk(expr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return literals, nil
|
||||
}
|
||||
|
||||
func descriptorByteLiterals(expr *ast.CompositeLit) ([]*ast.BasicLit, []byte, error) {
|
||||
arrayType, ok := expr.Type.(*ast.ArrayType)
|
||||
if !ok || arrayType.Len != nil {
|
||||
return nil, nil, fmt.Errorf("descriptor must be a byte slice literal")
|
||||
}
|
||||
elementType, ok := arrayType.Elt.(*ast.Ident)
|
||||
if !ok || (elementType.Name != "byte" && elementType.Name != "uint8") {
|
||||
return nil, nil, fmt.Errorf("descriptor must be a byte slice literal")
|
||||
}
|
||||
literals := make([]*ast.BasicLit, 0, len(expr.Elts))
|
||||
values := make([]byte, 0, len(expr.Elts))
|
||||
for _, element := range expr.Elts {
|
||||
literal, ok := element.(*ast.BasicLit)
|
||||
if !ok || literal.Kind != token.INT {
|
||||
return nil, nil, fmt.Errorf("descriptor byte slice has unsupported element %T", element)
|
||||
}
|
||||
value, err := strconv.ParseUint(literal.Value, 0, 8)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("decode descriptor byte %q: %w", literal.Value, err)
|
||||
}
|
||||
literals = append(literals, literal)
|
||||
values = append(values, byte(value))
|
||||
}
|
||||
return literals, values, nil
|
||||
}
|
||||
|
||||
// xorPBRawBytes hides the one generated raw descriptor in a protobuf source.
|
||||
// The complete expression is validated before changing the AST so a new protoc
|
||||
// output shape cannot result in a partially encoded descriptor.
|
||||
func xorPBRawBytes(src []byte) ([]byte, error) {
|
||||
fset := token.NewFileSet()
|
||||
fileAst, err := parser.ParseFile(fset, "", src, parser.ParseComments)
|
||||
func xorPBRawBytes(src []byte) []byte {
|
||||
var (
|
||||
fileAst *ast.File
|
||||
err error
|
||||
fset = token.NewFileSet()
|
||||
parserMode = parser.ParseComments
|
||||
)
|
||||
fileAst, err = parser.ParseFile(fset, "", src, parserMode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse protobuf source: %w", err)
|
||||
}
|
||||
declaration, valueSpec, descriptorName, err := findRawDescriptor(fileAst)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if (declaration.Tok != token.CONST && declaration.Tok != token.VAR) ||
|
||||
len(declaration.Specs) != 1 || len(valueSpec.Names) != 1 || len(valueSpec.Values) != 1 {
|
||||
return nil, fmt.Errorf("%s has unsupported declaration shape", descriptorName)
|
||||
}
|
||||
|
||||
functionName, stringLiterals, byteLiterals, byteValues, err := rawDescriptorLiterals(declaration, valueSpec, descriptorName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// Panic because this is mandatory for the agent to work
|
||||
panic(err)
|
||||
}
|
||||
var xorKey [8]byte
|
||||
// generate random xor key
|
||||
if _, err := rand.Read(xorKey[:]); err != nil {
|
||||
return nil, fmt.Errorf("generate protobuf descriptor key: %w", err)
|
||||
// Panic because this is mandatory for the agent to work
|
||||
panic(err)
|
||||
}
|
||||
encodeRawDescriptorLiterals(declaration, functionName, stringLiterals, byteLiterals, byteValues, xorKey[:])
|
||||
valueSpec.Values[0] = &ast.CallExpr{
|
||||
Fun: ast.NewIdent(functionName),
|
||||
Args: []ast.Expr{
|
||||
valueSpec.Values[0],
|
||||
ast.NewIdent("xorKey"),
|
||||
},
|
||||
}
|
||||
if err := appendRawDescriptorHelpers(fileAst, functionName, xorKey[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out bytes.Buffer
|
||||
if err := printer.Fprint(&out, fset, fileAst); err != nil {
|
||||
return nil, fmt.Errorf("print rewritten protobuf source: %w", err)
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
func findRawDescriptor(fileAst *ast.File) (*ast.GenDecl, *ast.ValueSpec, string, error) {
|
||||
var declaration *ast.GenDecl
|
||||
var valueSpec *ast.ValueSpec
|
||||
var descriptorName string
|
||||
for _, decl := range fileAst.Decls {
|
||||
genDecl, ok := decl.(*ast.GenDecl)
|
||||
ast.Inspect(fileAst, func(n ast.Node) bool {
|
||||
node, ok := n.(*ast.GenDecl)
|
||||
if !ok {
|
||||
continue
|
||||
return true
|
||||
}
|
||||
for _, spec := range genDecl.Specs {
|
||||
candidate, ok := spec.(*ast.ValueSpec)
|
||||
for _, spec := range node.Specs {
|
||||
valueSpec, ok := spec.(*ast.ValueSpec)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, name := range candidate.Names {
|
||||
if !strings.HasSuffix(name.Name, "_rawDesc") {
|
||||
for valueIndex, id := range valueSpec.Names {
|
||||
if !strings.HasSuffix(id.Name, "_rawDesc") || strings.HasSuffix(id.Name, "_rawDescData") {
|
||||
continue
|
||||
}
|
||||
if declaration != nil {
|
||||
return nil, nil, "", fmt.Errorf("multiple protobuf raw descriptors found")
|
||||
if valueIndex >= len(valueSpec.Values) {
|
||||
continue
|
||||
}
|
||||
|
||||
switch rawDesc := valueSpec.Values[valueIndex].(type) {
|
||||
case *ast.CompositeLit:
|
||||
for i, v := range rawDesc.Elts {
|
||||
elt := v.(*ast.BasicLit)
|
||||
elt.Value = xorByte(elt.Value, xorKey[i%len(xorKey)])
|
||||
}
|
||||
valueSpec.Values[valueIndex] = &ast.CallExpr{
|
||||
Fun: ast.NewIdent("xorBytes"),
|
||||
Args: []ast.Expr{
|
||||
rawDesc,
|
||||
ast.NewIdent("xorKey"),
|
||||
},
|
||||
}
|
||||
default:
|
||||
offset := 0
|
||||
if xorStringExpr(rawDesc, xorKey, &offset) {
|
||||
node.Tok = token.VAR
|
||||
valueSpec.Values[valueIndex] = &ast.CallExpr{
|
||||
Fun: ast.NewIdent("xorString"),
|
||||
Args: []ast.Expr{
|
||||
rawDesc,
|
||||
ast.NewIdent("xorKey"),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
declaration, valueSpec, descriptorName = genDecl, candidate, name.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
if declaration == nil {
|
||||
return nil, nil, "", fmt.Errorf("no protobuf raw descriptor found")
|
||||
}
|
||||
return declaration, valueSpec, descriptorName, nil
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
func rawDescriptorLiterals(declaration *ast.GenDecl, valueSpec *ast.ValueSpec, descriptorName string) (string, []*ast.BasicLit, []*ast.BasicLit, []byte, error) {
|
||||
var stringLiterals []*ast.BasicLit
|
||||
var byteLiterals []*ast.BasicLit
|
||||
var byteValues []byte
|
||||
var err error
|
||||
functionName := "xorString"
|
||||
if byteExpr, ok := valueSpec.Values[0].(*ast.CompositeLit); ok {
|
||||
if declaration.Tok != token.VAR {
|
||||
return "", nil, nil, nil, fmt.Errorf("%s byte descriptor is not a variable", descriptorName)
|
||||
}
|
||||
byteLiterals, byteValues, err = descriptorByteLiterals(byteExpr)
|
||||
functionName = "xorBytes"
|
||||
} else {
|
||||
stringLiterals, err = descriptorStringLiterals(valueSpec.Values[0])
|
||||
}
|
||||
if err != nil {
|
||||
return "", nil, nil, nil, fmt.Errorf("%s: %w", descriptorName, err)
|
||||
}
|
||||
return functionName, stringLiterals, byteLiterals, byteValues, nil
|
||||
}
|
||||
|
||||
func encodeRawDescriptorLiterals(declaration *ast.GenDecl, functionName string, stringLiterals, byteLiterals []*ast.BasicLit, byteValues, xorKey []byte) {
|
||||
if functionName == "xorString" {
|
||||
offset := 0
|
||||
for _, literal := range stringLiterals {
|
||||
plain, _ := strconv.Unquote(literal.Value) // Validated above.
|
||||
encoded := []byte(plain)
|
||||
for i := range encoded {
|
||||
encoded[i] ^= xorKey[(offset+i)%len(xorKey)]
|
||||
}
|
||||
offset += len(encoded)
|
||||
literal.Value = strconv.Quote(string(encoded))
|
||||
}
|
||||
declaration.Tok = token.VAR
|
||||
} else {
|
||||
for i, literal := range byteLiterals {
|
||||
literal.Value = fmt.Sprintf("0x%02x", byteValues[i]^xorKey[i%len(xorKey)])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func appendRawDescriptorHelpers(fileAst *ast.File, functionName string, xorKey []byte) error {
|
||||
helper := `func xorString(input string, key []byte) string {
|
||||
out := make([]byte, len(input))
|
||||
for i := 0; i < len(input); i++ {
|
||||
out[i] = input[i] ^ key[i%len(key)]
|
||||
}
|
||||
return string(out)
|
||||
}`
|
||||
if functionName == "xorBytes" {
|
||||
helper = `func xorBytes(input []byte, key []byte) []byte {
|
||||
fileAst.Decls = append(fileAst.Decls,
|
||||
parseHelperDecl(`func xorBytes(input []byte, key []byte) []byte {
|
||||
out := make([]byte, len(input))
|
||||
for i := range input {
|
||||
out[i] = input[i] ^ key[i%len(key)]
|
||||
}
|
||||
return out
|
||||
}`
|
||||
}`),
|
||||
parseHelperDecl(`func xorString(input string, key []byte) string {
|
||||
out := make([]byte, len(input))
|
||||
for i := 0; i < len(input); i++ {
|
||||
out[i] = input[i] ^ key[i%len(key)]
|
||||
}
|
||||
helperDecl, err := parseHelperDecl(helper)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fileAst.Decls = append(fileAst.Decls, helperDecl)
|
||||
return string(out)
|
||||
}`),
|
||||
)
|
||||
|
||||
xorTokens := make([]ast.Expr, len(xorKey))
|
||||
// map xorKey to a slice of ast.BasicLit
|
||||
for i, b := range xorKey {
|
||||
xorTokens[i] = &ast.BasicLit{Kind: token.INT, Value: fmt.Sprintf("0x%x", b)}
|
||||
xorTokens[i] = &ast.BasicLit{
|
||||
Kind: token.INT,
|
||||
Value: fmt.Sprintf("0x%x", b),
|
||||
}
|
||||
}
|
||||
fileAst.Decls = append(fileAst.Decls, &ast.GenDecl{
|
||||
|
||||
// add the global xorKey variable to the AST
|
||||
fileAst.Decls = append(fileAst.Decls, ast.Decl(&ast.GenDecl{
|
||||
Tok: token.VAR,
|
||||
Specs: []ast.Spec{&ast.ValueSpec{
|
||||
Names: []*ast.Ident{ast.NewIdent("xorKey")},
|
||||
Values: []ast.Expr{&ast.CompositeLit{Type: ast.NewIdent("[]byte"), Elts: xorTokens}},
|
||||
}},
|
||||
})
|
||||
return nil
|
||||
Specs: []ast.Spec{
|
||||
&ast.ValueSpec{
|
||||
Names: []*ast.Ident{ast.NewIdent("xorKey")},
|
||||
Values: []ast.Expr{
|
||||
&ast.CompositeLit{
|
||||
Type: ast.NewIdent("[]byte"),
|
||||
Elts: xorTokens,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
outBuff := bytes.Buffer{}
|
||||
// Render the AST as Go code
|
||||
printer.Fprint(&outBuff, fset, fileAst)
|
||||
return outBuff.Bytes()
|
||||
}
|
||||
|
||||
func parseHelperDecl(src string) (ast.Decl, error) {
|
||||
func parseHelperDecl(src string) ast.Decl {
|
||||
fileAst, err := parser.ParseFile(token.NewFileSet(), "", "package assets\n"+src, 0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse protobuf descriptor helper: %w", err)
|
||||
panic(err)
|
||||
}
|
||||
return fileAst.Decls[0]
|
||||
}
|
||||
|
||||
func xorByte(raw string, key byte) string {
|
||||
// strip 0x
|
||||
raw = raw[2:]
|
||||
if len(raw) == 1 {
|
||||
// Because we got `0x8` at some point
|
||||
raw = fmt.Sprintf("0%s", raw)
|
||||
}
|
||||
hexByte, err := hex.DecodeString(raw)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
newByte := hex.EncodeToString([]byte{hexByte[0] ^ key})
|
||||
return fmt.Sprintf("0x%s", newByte)
|
||||
}
|
||||
|
||||
func xorStringExpr(expr ast.Expr, key [8]byte, offset *int) bool {
|
||||
switch node := expr.(type) {
|
||||
case *ast.BasicLit:
|
||||
if node.Kind != token.STRING {
|
||||
return false
|
||||
}
|
||||
raw, err := strconv.Unquote(node.Value)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
rawBytes := []byte(raw)
|
||||
for i := range rawBytes {
|
||||
rawBytes[i] ^= key[(*offset+i)%len(key)]
|
||||
}
|
||||
*offset += len(rawBytes)
|
||||
node.Value = strconv.Quote(string(rawBytes))
|
||||
return true
|
||||
case *ast.BinaryExpr:
|
||||
if node.Op != token.ADD {
|
||||
return false
|
||||
}
|
||||
leftOkay := xorStringExpr(node.X, key, offset)
|
||||
rightOkay := xorStringExpr(node.Y, key, offset)
|
||||
return leftOkay && rightOkay
|
||||
case *ast.ParenExpr:
|
||||
return xorStringExpr(node.X, key, offset)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return fileAst.Decls[0], nil
|
||||
}
|
||||
|
||||
func unzip(src string, dest string) ([]string, error) {
|
||||
|
||||
@@ -1,407 +0,0 @@
|
||||
package assets
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
protobufs "github.com/bishopfox/sliver/protobuf"
|
||||
)
|
||||
|
||||
func parseProtobufSource(t *testing.T, src []byte) *ast.File {
|
||||
t.Helper()
|
||||
file, err := parser.ParseFile(token.NewFileSet(), "protobuf.pb.go", src, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse protobuf source: %v", err)
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
func namedValue(t *testing.T, file *ast.File, name string) (token.Token, ast.Expr) {
|
||||
t.Helper()
|
||||
for _, decl := range file.Decls {
|
||||
gen, ok := decl.(*ast.GenDecl)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, spec := range gen.Specs {
|
||||
value, ok := spec.(*ast.ValueSpec)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for i, ident := range value.Names {
|
||||
if ident.Name == name && i < len(value.Values) {
|
||||
return gen.Tok, value.Values[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("declaration %s not found", name)
|
||||
return token.ILLEGAL, nil
|
||||
}
|
||||
|
||||
func descriptorLiteralBytes(t *testing.T, expr ast.Expr) []byte {
|
||||
t.Helper()
|
||||
switch value := expr.(type) {
|
||||
case *ast.BasicLit:
|
||||
if value.Kind != token.STRING {
|
||||
t.Fatalf("descriptor literal has token %s, want STRING", value.Kind)
|
||||
}
|
||||
unquoted, err := strconv.Unquote(value.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("unquote descriptor literal: %v", err)
|
||||
}
|
||||
return []byte(unquoted)
|
||||
case *ast.BinaryExpr:
|
||||
if value.Op != token.ADD {
|
||||
t.Fatalf("descriptor expression uses %s, want +", value.Op)
|
||||
}
|
||||
return append(descriptorLiteralBytes(t, value.X), descriptorLiteralBytes(t, value.Y)...)
|
||||
case *ast.ParenExpr:
|
||||
return descriptorLiteralBytes(t, value.X)
|
||||
case *ast.CompositeLit:
|
||||
decoded := make([]byte, len(value.Elts))
|
||||
for i, element := range value.Elts {
|
||||
literal, ok := element.(*ast.BasicLit)
|
||||
if !ok || literal.Kind != token.INT {
|
||||
t.Fatalf("descriptor byte %d has type %T, want integer literal", i, element)
|
||||
}
|
||||
n, err := strconv.ParseUint(literal.Value, 0, 8)
|
||||
if err != nil {
|
||||
t.Fatalf("parse descriptor byte %d: %v", i, err)
|
||||
}
|
||||
decoded[i] = byte(n)
|
||||
}
|
||||
return decoded
|
||||
default:
|
||||
t.Fatalf("unsupported descriptor expression %T", expr)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func decodedTransformedDescriptor(t *testing.T, file *ast.File, name string) []byte {
|
||||
t.Helper()
|
||||
_, expr := namedValue(t, file, name)
|
||||
call, ok := expr.(*ast.CallExpr)
|
||||
if !ok || len(call.Args) != 2 {
|
||||
t.Fatalf("%s initializer = %T, want decoder call with two arguments", name, expr)
|
||||
}
|
||||
decoder, ok := call.Fun.(*ast.Ident)
|
||||
if !ok || (decoder.Name != "xorString" && decoder.Name != "xorBytes") {
|
||||
t.Fatalf("%s uses decoder %T, want xorString or xorBytes", name, call.Fun)
|
||||
}
|
||||
keyRef, ok := call.Args[1].(*ast.Ident)
|
||||
if !ok || keyRef.Name != "xorKey" {
|
||||
t.Fatalf("%s decoder does not use xorKey", name)
|
||||
}
|
||||
_, keyExpr := namedValue(t, file, "xorKey")
|
||||
key := descriptorLiteralBytes(t, keyExpr)
|
||||
if len(key) == 0 {
|
||||
t.Fatal("xorKey is empty")
|
||||
}
|
||||
encoded := descriptorLiteralBytes(t, call.Args[0])
|
||||
decoded := make([]byte, len(encoded))
|
||||
for i := range encoded {
|
||||
decoded[i] = encoded[i] ^ key[i%len(key)]
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
func TestXorPBRawBytesPreservesEmbeddedDescriptors(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
name string
|
||||
}{
|
||||
{"sliverpb/sliver.pb.go", "file_sliverpb_sliver_proto_rawDesc"},
|
||||
{"commonpb/common.pb.go", "file_commonpb_common_proto_rawDesc"},
|
||||
{"dnspb/dns.pb.go", "file_dnspb_dns_proto_rawDesc"},
|
||||
} {
|
||||
t.Run(tc.path, func(t *testing.T) {
|
||||
source, err := protobufs.FS.ReadFile(tc.path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, originalExpr := namedValue(t, parseProtobufSource(t, source), tc.name)
|
||||
want := descriptorLiteralBytes(t, originalExpr)
|
||||
|
||||
transformed, err := xorPBRawBytes(source)
|
||||
if err != nil {
|
||||
t.Fatalf("transform descriptor: %v", err)
|
||||
}
|
||||
got := decodedTransformedDescriptor(t, parseProtobufSource(t, transformed), tc.name)
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("decoded descriptor differs from embedded %s (%d versus %d bytes)", tc.path, len(got), len(want))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestXorPBRawBytesRejectsUnsupportedDescriptorShapes(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
src string
|
||||
}{
|
||||
{"missing descriptor", "package p\nconst other = \"abc\"\n"},
|
||||
{"unsupported suffix", "package p\nconst suffix = \"def\"\nconst file_x_rawDesc = \"abc\" + suffix\n"},
|
||||
{"grouped declaration", "package p\nconst (\nfile_x_rawDesc = \"abc\"\nother = \"def\"\n)\n"},
|
||||
{"unsupported byte element", "package p\nvar value = byte(1)\nvar file_x_rawDesc = []byte{0x00, value}\n"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
source := []byte(tc.src)
|
||||
original := bytes.Clone(source)
|
||||
if _, err := xorPBRawBytes(source); err == nil {
|
||||
t.Fatal("expected an unsupported descriptor shape to fail")
|
||||
}
|
||||
if !bytes.Equal(source, original) {
|
||||
t.Fatal("failed transform modified its input")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestXorPBRawBytesSupportsByteLiterals(t *testing.T) {
|
||||
const source = "package p\nvar file_x_rawDesc = []byte{0x00, 1, 0xff}\n"
|
||||
transformed, err := xorPBRawBytes([]byte(source))
|
||||
if err != nil {
|
||||
t.Fatalf("transform byte descriptor: %v", err)
|
||||
}
|
||||
got := decodedTransformedDescriptor(t, parseProtobufSource(t, transformed), "file_x_rawDesc")
|
||||
if want := []byte{0, 1, 255}; !bytes.Equal(got, want) {
|
||||
t.Fatalf("decoded byte descriptor = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
type protobufFieldTag struct {
|
||||
structName string
|
||||
fieldName string
|
||||
protobuf string
|
||||
json string
|
||||
raw string
|
||||
}
|
||||
|
||||
func protobufFieldTags(t *testing.T, src []byte) []protobufFieldTag {
|
||||
t.Helper()
|
||||
file := parseProtobufSource(t, src)
|
||||
tags := []protobufFieldTag{}
|
||||
for _, decl := range file.Decls {
|
||||
gen, ok := decl.(*ast.GenDecl)
|
||||
if !ok || gen.Tok != token.TYPE {
|
||||
continue
|
||||
}
|
||||
for _, spec := range gen.Specs {
|
||||
typeSpec, ok := spec.(*ast.TypeSpec)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
structure, ok := typeSpec.Type.(*ast.StructType)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, field := range structure.Fields.List {
|
||||
if field.Tag == nil {
|
||||
continue
|
||||
}
|
||||
raw, err := strconv.Unquote(field.Tag.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("unquote %s struct tag: %v", typeSpec.Name.Name, err)
|
||||
}
|
||||
protoTag, ok := reflect.StructTag(raw).Lookup("protobuf")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
jsonTag := reflect.StructTag(raw).Get("json")
|
||||
for _, name := range field.Names {
|
||||
tags = append(tags, protobufFieldTag{typeSpec.Name.Name, name.Name, protoTag, jsonTag, raw})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
func nameFromTag(t *testing.T, value string) string {
|
||||
t.Helper()
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
if strings.HasPrefix(part, "name=") {
|
||||
return strings.TrimPrefix(part, "name=")
|
||||
}
|
||||
}
|
||||
t.Fatalf("protobuf tag %q has no name", value)
|
||||
return ""
|
||||
}
|
||||
|
||||
func enumFromTag(value string) string {
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
if strings.HasPrefix(part, "enum=") {
|
||||
return strings.TrimPrefix(part, "enum=")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func tagWithoutNames(value string) string {
|
||||
parts := strings.Split(value, ",")
|
||||
for i, part := range parts {
|
||||
if strings.HasPrefix(part, "name=") {
|
||||
parts[i] = "name=*"
|
||||
} else if strings.HasPrefix(part, "enum=") {
|
||||
parts[i] = "enum=*"
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
func assertRewrittenProtobufFieldTag(t *testing.T, index int, old, newTag protobufFieldTag) {
|
||||
t.Helper()
|
||||
if old.structName != newTag.structName || old.fieldName != newTag.fieldName {
|
||||
t.Fatalf("field %d changed from %s.%s to %s.%s", index, old.structName, old.fieldName, newTag.structName, newTag.fieldName)
|
||||
}
|
||||
if tagWithoutNames(old.protobuf) != tagWithoutNames(newTag.protobuf) {
|
||||
t.Fatalf("wire tag changed for %s.%s: %q -> %q", old.structName, old.fieldName, old.protobuf, newTag.protobuf)
|
||||
}
|
||||
oldName := nameFromTag(t, old.protobuf)
|
||||
newName := nameFromTag(t, newTag.protobuf)
|
||||
if oldName == newName || len(oldName) != len(newName) {
|
||||
t.Fatalf("field name was not rewritten with the same length for %s.%s: %q -> %q", old.structName, old.fieldName, oldName, newName)
|
||||
}
|
||||
oldJSON := strings.SplitN(old.json, ",", 2)
|
||||
newJSON := strings.SplitN(newTag.json, ",", 2)
|
||||
if len(oldJSON) != len(newJSON) || oldJSON[0] == newJSON[0] || len(oldJSON[0]) != len(newJSON[0]) || strings.Join(oldJSON[1:], ",") != strings.Join(newJSON[1:], ",") {
|
||||
t.Fatalf("JSON tag options changed for %s.%s: %q -> %q", old.structName, old.fieldName, old.json, newTag.json)
|
||||
}
|
||||
oldEnum, newEnum := enumFromTag(old.protobuf), enumFromTag(newTag.protobuf)
|
||||
if oldEnum != "" && (newEnum == oldEnum || len(newEnum) != len(oldEnum)) {
|
||||
t.Fatalf("enum tag was not rewritten with the same length for %s.%s: %q -> %q", old.structName, old.fieldName, oldEnum, newEnum)
|
||||
}
|
||||
}
|
||||
|
||||
func assertUnchangedMapEntryTags(t *testing.T, before, after []protobufFieldTag) int {
|
||||
t.Helper()
|
||||
count := 0
|
||||
for i, old := range before {
|
||||
original := reflect.StructTag(old.raw)
|
||||
key, hasKey := original.Lookup("protobuf_key")
|
||||
value, hasValue := original.Lookup("protobuf_val")
|
||||
if !hasKey && !hasValue {
|
||||
continue
|
||||
}
|
||||
if !hasKey || !hasValue {
|
||||
t.Fatalf("incomplete map entry tags for %s.%s", old.structName, old.fieldName)
|
||||
}
|
||||
rewritten := reflect.StructTag(after[i].raw)
|
||||
if rewritten.Get("protobuf_key") != key || rewritten.Get("protobuf_val") != value {
|
||||
t.Fatalf("map entry tags changed for %s.%s", old.structName, old.fieldName)
|
||||
}
|
||||
count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func TestRewriteProtobufStructTagsPreservesSliverpbWireNumbers(t *testing.T) {
|
||||
source, err := protobufs.FS.ReadFile("sliverpb/sliver.pb.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
transformed, err := rewriteProtobufStructTags(source)
|
||||
if err != nil {
|
||||
t.Fatalf("rewrite protobuf tags: %v", err)
|
||||
}
|
||||
before := protobufFieldTags(t, source)
|
||||
after := protobufFieldTags(t, transformed)
|
||||
if len(before) != 656 || len(after) != len(before) {
|
||||
t.Fatalf("protobuf tag coverage = %d original, %d rewritten; want 656 each", len(before), len(after))
|
||||
}
|
||||
rewrittenByOriginal := make(map[string]string)
|
||||
packedFound := false
|
||||
for i, old := range before {
|
||||
newTag := after[i]
|
||||
assertRewrittenProtobufFieldTag(t, i, old, newTag)
|
||||
if old.structName == "PivotStartListenerReq" && old.fieldName == "Options" {
|
||||
packedFound = true
|
||||
if !strings.Contains(newTag.protobuf, ",packed,") {
|
||||
t.Fatalf("packed Options tag lost packed option: %q", newTag.protobuf)
|
||||
}
|
||||
}
|
||||
if earlier, exists := rewrittenByOriginal[old.raw]; exists && earlier != newTag.raw {
|
||||
t.Fatalf("identical original tag %q rewrote inconsistently", old.raw)
|
||||
}
|
||||
rewrittenByOriginal[old.raw] = newTag.raw
|
||||
}
|
||||
if !packedFound {
|
||||
t.Fatal("packed PivotStartListenerReq.Options tag not found")
|
||||
}
|
||||
if count := assertUnchangedMapEntryTags(t, before, after); count != 3 {
|
||||
t.Fatalf("map entry tag coverage = %d, want 3", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteProtobufStructTagsRejectsMissingTags(t *testing.T) {
|
||||
if _, err := rewriteProtobufStructTags([]byte("package p\ntype Empty struct{}\n")); err == nil {
|
||||
t.Fatal("expected a source without protobuf field tags to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupGoPathRewritesAllImplantProtobufFieldTags(t *testing.T) {
|
||||
destination := t.TempDir()
|
||||
if err := SetupGoPath(destination, true); err != nil {
|
||||
t.Fatalf("prepare implant protobuf source: %v", err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
fieldCount int
|
||||
}{
|
||||
{"sliverpb/sliver.pb.go", 656},
|
||||
{"commonpb/common.pb.go", 19},
|
||||
{"dnspb/dns.pb.go", 6},
|
||||
} {
|
||||
t.Run(tc.path, func(t *testing.T) {
|
||||
original, err := protobufs.FS.ReadFile(tc.path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
generated, err := os.ReadFile(filepath.Join(destination, "protobuf", filepath.FromSlash(tc.path)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := protobufFieldTags(t, original)
|
||||
after := protobufFieldTags(t, generated)
|
||||
if len(before) != tc.fieldCount || len(after) != len(before) {
|
||||
t.Fatalf("protobuf field tags = %d original, %d generated; want %d each", len(before), len(after), tc.fieldCount)
|
||||
}
|
||||
for i := range before {
|
||||
assertRewrittenProtobufFieldTag(t, i, before[i], after[i])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupGoPathReportsTransformedFileWriteErrors(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
path string
|
||||
includeDNS bool
|
||||
}{
|
||||
{"sliver", "protobuf/sliverpb/sliver.pb.go", false},
|
||||
{"common", "protobuf/commonpb/common.pb.go", false},
|
||||
{"dns", "protobuf/dnspb/dns.pb.go", true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
destination := t.TempDir()
|
||||
blocker := filepath.Join(destination, filepath.FromSlash(tc.path))
|
||||
if err := os.MkdirAll(blocker, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := SetupGoPath(destination, tc.includeDNS)
|
||||
if err == nil || !strings.Contains(err.Error(), filepath.Base(blocker)) {
|
||||
t.Fatalf("SetupGoPath() error = %v, want path %s", err, blocker)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -20,10 +20,11 @@ package traffic_encoders
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
_ "embed"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
implantEncoders "github.com/bishopfox/sliver/implant/sliver/encoders/traffic"
|
||||
serverEncoders "github.com/bishopfox/sliver/util/encoders/traffic"
|
||||
@@ -33,124 +34,102 @@ import (
|
||||
var hexWASM []byte
|
||||
|
||||
func TestTrafficEncoderCompatibility_hex(t *testing.T) {
|
||||
|
||||
// Hex
|
||||
|
||||
implantSideHex, err := implantEncoders.CreateTrafficEncoder("hex", hexWASM, func(msg string) {
|
||||
t.Log(msg)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := implantSideHex.Close(); err != nil {
|
||||
t.Errorf("close implant encoder: %v", err)
|
||||
}
|
||||
})
|
||||
serverSideHex, err := serverEncoders.CreateTrafficEncoder("hex", hexWASM, func(msg string) {
|
||||
t.Log(msg)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := serverSideHex.Close(); err != nil {
|
||||
t.Errorf("close server encoder: %v", err)
|
||||
|
||||
data := make([]byte, 1024)
|
||||
_, err = rand.Read(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
encodedData, err := implantSideHex.Encode(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decodedData, err := serverSideHex.Decode(encodedData)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(data, decodedData) {
|
||||
t.Fatal("Decoded data does not match original")
|
||||
}
|
||||
|
||||
data = make([]byte, 1024)
|
||||
_, err = rand.Read(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
encodedData, err = serverSideHex.Encode(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decodedData, err = implantSideHex.Decode(encodedData)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(data, decodedData) {
|
||||
t.Fatal("Decoded data does not match original")
|
||||
}
|
||||
}
|
||||
|
||||
// Encoder specific tests
|
||||
func TestHexPerformance(t *testing.T) {
|
||||
|
||||
sizes := []int{1024, 1024 * 1024, 2 * 1024 * 1024, 4 * 1024 * 1024}
|
||||
|
||||
// Stock encoder
|
||||
for i := 0; i < len(sizes); i++ {
|
||||
originalValue := make([]byte, sizes[i])
|
||||
rand.Read(originalValue)
|
||||
stock := time.Now()
|
||||
encodedValue := hex.EncodeToString(originalValue)
|
||||
decodedValue, err := hex.DecodeString(encodedValue)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
directions := []struct {
|
||||
name string
|
||||
encode func([]byte) ([]byte, error)
|
||||
decode func([]byte) ([]byte, error)
|
||||
}{
|
||||
{"implant_to_server", implantSideHex.Encode, serverSideHex.Decode},
|
||||
{"server_to_implant", serverSideHex.Encode, implantSideHex.Decode},
|
||||
}
|
||||
for _, direction := range directions {
|
||||
t.Run(direction.name, func(t *testing.T) {
|
||||
// Exercise empty and binary inputs, both sides of a 64 KiB WASM
|
||||
// page boundary, and the largest payload from the old timing test.
|
||||
for _, size := range []int{0, 1, 256, 65535, 65536, 65537, 4 * 1024 * 1024} {
|
||||
t.Run(fmt.Sprintf("bytes_%d", size), func(t *testing.T) {
|
||||
payload := hexTestPayload(size)
|
||||
encoded, err := direction.encode(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("encode: %v", err)
|
||||
}
|
||||
if !bytes.Equal(encoded, []byte(hex.EncodeToString(payload))) {
|
||||
t.Fatal("WASM output does not match standard hex encoding")
|
||||
}
|
||||
decoded, err := direction.decode(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !bytes.Equal(payload, decoded) {
|
||||
t.Fatalf("round trip changed the %d-byte payload (decoded %d bytes)", len(payload), len(decoded))
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkHex(b *testing.B) {
|
||||
for _, size := range []int{1024, 1024 * 1024, 2 * 1024 * 1024, 4 * 1024 * 1024} {
|
||||
payload := hexTestPayload(size)
|
||||
b.Run(fmt.Sprintf("bytes_%d", size), func(b *testing.B) {
|
||||
b.Run("standard_round_trip", func(b *testing.B) {
|
||||
b.SetBytes(int64(size))
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
encoded := hex.EncodeToString(payload)
|
||||
decoded, err := hex.DecodeString(encoded)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(payload, decoded) {
|
||||
b.Fatal("decoded data does not match original")
|
||||
}
|
||||
}
|
||||
})
|
||||
b.Run("wasm_fresh_runtime_round_trip", func(b *testing.B) {
|
||||
b.SetBytes(int64(size))
|
||||
b.ReportAllocs()
|
||||
// Include runtime creation and close in each iteration. The WASM
|
||||
// API retains output buffers until the runtime is closed, so an
|
||||
// unbounded loop over one runtime would accumulate guest memory.
|
||||
for b.Loop() {
|
||||
benchmarkHexWASMLifecycle(b, payload)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func benchmarkHexWASMLifecycle(b *testing.B, payload []byte) {
|
||||
b.Helper()
|
||||
encoder, err := serverEncoders.CreateTrafficEncoder("hex", hexWASM, func(string) {})
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if err := encoder.Close(); err != nil {
|
||||
b.Errorf("close encoder: %v", err)
|
||||
t.Logf("Stock encoder took %v (%d bytes)", time.Since(stock), sizes[i])
|
||||
if !bytes.Equal(originalValue, decodedValue) {
|
||||
t.Fatalf("Expected %v but got %v", originalValue, decodedValue)
|
||||
}
|
||||
}()
|
||||
encoded, err := encoder.Encode(payload)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
decoded, err := encoder.Decode(encoded)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(payload, decoded) {
|
||||
b.Fatal("decoded data does not match original")
|
||||
}
|
||||
}
|
||||
|
||||
func hexTestPayload(size int) []byte {
|
||||
payload := make([]byte, size)
|
||||
for i := range payload {
|
||||
payload[i] = byte(i)
|
||||
// Traffic encoder
|
||||
for i := 0; i < len(sizes); i++ {
|
||||
encoder, err := serverEncoders.CreateTrafficEncoder("hex", hexWASM, func(msg string) {
|
||||
t.Log(msg)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer encoder.Close()
|
||||
originalValue := make([]byte, sizes[i])
|
||||
rand.Read(originalValue)
|
||||
start := time.Now()
|
||||
encodedValue, err := encoder.Encode(originalValue)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// t.Logf("Got encoded value (%d bytes)", len(encodedValue))
|
||||
decodedValue, err := encoder.Decode(encodedValue)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("WASM Hex encoder took %v (%d bytes)", time.Since(start), sizes[i])
|
||||
if !bytes.Equal(originalValue, decodedValue) {
|
||||
t.Fatalf("Expected %v but got %v", originalValue, decodedValue)
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
+41
-40
@@ -46,7 +46,6 @@ import (
|
||||
"time"
|
||||
|
||||
consts "github.com/bishopfox/sliver/client/constants"
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
"github.com/bishopfox/sliver/protobuf/dnspb"
|
||||
"github.com/bishopfox/sliver/protobuf/sliverpb"
|
||||
"github.com/bishopfox/sliver/server/core"
|
||||
@@ -577,7 +576,15 @@ func (s *SliverDNSServer) HandleDNSRequest(domains []string, canaries bool, writ
|
||||
return
|
||||
}
|
||||
|
||||
resp := s.handleDNSQuery(domains, canaries, req)
|
||||
var resp *dns.Msg
|
||||
isC2, domain := s.isC2SubDomain(domains, req.Question[0].Name)
|
||||
if isC2 {
|
||||
dnsLog.Debugf("'%s' is subdomain of c2 parent '%s'", req.Question[0].Name, domain)
|
||||
resp = s.handleC2(domain, req)
|
||||
} else if canaries {
|
||||
dnsLog.Debugf("checking '%s' for DNS canary matches", req.Question[0].Name)
|
||||
resp = s.handleCanary(req)
|
||||
}
|
||||
if resp != nil {
|
||||
// These responses often have near-max-length QNAMEs. Enable compression to
|
||||
// keep UDP responses under the minimum DNS message size limits.
|
||||
@@ -588,27 +595,6 @@ func (s *SliverDNSServer) HandleDNSRequest(domains []string, canaries bool, writ
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SliverDNSServer) handleDNSQuery(domains []string, canaries bool, req *dns.Msg) *dns.Msg {
|
||||
if canaries {
|
||||
canary, err := db.CanaryByDomain(req.Question[0].Name)
|
||||
if err == nil && canary != nil {
|
||||
return s.handleCanary(req, canary)
|
||||
}
|
||||
if err != nil && !errors.Is(err, db.ErrRecordNotFound) {
|
||||
dnsLog.Errorf("Failed to look up DNS canary: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
if isC2, domain := s.isC2SubDomain(domains, req.Question[0].Name); isC2 {
|
||||
dnsLog.Debugf("'%s' is subdomain of c2 parent '%s'", req.Question[0].Name, domain)
|
||||
return s.handleC2(domain, req)
|
||||
}
|
||||
if canaries {
|
||||
return s.nameErrorResp(req)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Returns true if the requested domain is a c2 subdomain, and the domain it matched with
|
||||
func (s *SliverDNSServer) isC2SubDomain(domains []string, reqDomain string) (bool, string) {
|
||||
for _, parentDomain := range domains {
|
||||
@@ -1156,24 +1142,39 @@ func (s *SliverDNSServer) handleNOP(domain string, msg *dnspb.DNSMessage, checks
|
||||
// Canary Record Handler
|
||||
// ---------------------------
|
||||
// Canary -> valid? -> trigger alert event
|
||||
func (s *SliverDNSServer) handleCanary(req *dns.Msg, canary *clientpb.DNSCanary) *dns.Msg {
|
||||
firstTrigger, err := generate.TriggerCanary(canary)
|
||||
if err != nil {
|
||||
dnsLog.Errorf("Failed to record DNS canary trigger for '%s': %s", canary.Domain, err)
|
||||
return s.nameErrorResp(req)
|
||||
}
|
||||
func (s *SliverDNSServer) handleCanary(req *dns.Msg) *dns.Msg {
|
||||
// Don't block, return error as fast as possible
|
||||
go func() {
|
||||
defer recoverAndLogPanic(dnsLog.Errorf, "dns handleCanary")
|
||||
|
||||
dnsLog.Warnf("DNS canary tripped for '%s'", canary.ImplantName)
|
||||
if firstTrigger {
|
||||
// Publish only after the trigger transaction commits successfully.
|
||||
core.EventBroker.Publish(core.Event{
|
||||
Session: &core.Session{
|
||||
Name: canary.ImplantName,
|
||||
},
|
||||
Data: []byte(canary.Domain),
|
||||
EventType: consts.CanaryEvent,
|
||||
})
|
||||
}
|
||||
reqDomain := strings.ToLower(req.Question[0].Name)
|
||||
if !strings.HasSuffix(reqDomain, ".") {
|
||||
reqDomain += "." // Ensure we have the FQDN
|
||||
}
|
||||
canary, err := db.CanaryByDomain(reqDomain)
|
||||
if err != nil {
|
||||
dnsLog.Errorf("Failed to find canary: %s", err)
|
||||
return
|
||||
}
|
||||
if canary != nil {
|
||||
dnsLog.Warnf("DNS canary tripped for '%s'", canary.ImplantName)
|
||||
if !canary.Triggered {
|
||||
// Defer publishing the event until we're sure the db is sync'd
|
||||
defer core.EventBroker.Publish(core.Event{
|
||||
Session: &core.Session{
|
||||
Name: canary.ImplantName,
|
||||
},
|
||||
Data: []byte(canary.Domain),
|
||||
EventType: consts.CanaryEvent,
|
||||
})
|
||||
canary.Triggered = true
|
||||
canary.FirstTriggered = time.Now().Format(time.RFC1123)
|
||||
}
|
||||
canary.LatestTrigger = time.Now().Format(time.RFC1123)
|
||||
canary.Count++
|
||||
generate.UpdateCanary(canary)
|
||||
}
|
||||
}()
|
||||
return s.nameErrorResp(req)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,297 +0,0 @@
|
||||
package c2
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"hash/crc32"
|
||||
"net"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
consts "github.com/bishopfox/sliver/client/constants"
|
||||
"github.com/bishopfox/sliver/protobuf/dnspb"
|
||||
"github.com/bishopfox/sliver/server/core"
|
||||
"github.com/bishopfox/sliver/server/db"
|
||||
"github.com/bishopfox/sliver/server/db/models"
|
||||
"github.com/miekg/dns"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestDNSCanarySameParentTriggersOnce(t *testing.T) {
|
||||
const parent = "canary-parent.invalid."
|
||||
// Legacy records can contain mixed-case parent names, and DNS queries are
|
||||
// case-insensitive independently of how the registered name was spelled.
|
||||
canary := createDNSCanaryRecord(t, models.NewUUID().String()+".CaNaRy-PaReNt.InVaLiD.")
|
||||
events := subscribeDNSCanaryEvents(t)
|
||||
lookups := countDNSCanaryLookups(t)
|
||||
server := newTestDNSServer()
|
||||
|
||||
response := queryDNSCanary(t, server, parent, strings.ToUpper(canary.Domain), true)
|
||||
assertDNSCanaryResponse(t, response)
|
||||
if got := lookups.Load(); got != 1 {
|
||||
t.Fatalf("first query performed %d canary lookups, want 1", got)
|
||||
}
|
||||
first := assertDNSCanaryRecord(t, canary, 1)
|
||||
assertDNSCanaryEvents(t, collectDNSCanaryEvents(t, events, false), canary, 1)
|
||||
|
||||
lookups.Store(0)
|
||||
response = queryDNSCanary(t, server, parent, strings.ToLower(canary.Domain), true)
|
||||
assertDNSCanaryResponse(t, response)
|
||||
if got := lookups.Load(); got != 1 {
|
||||
t.Fatalf("repeat query performed %d canary lookups, want 1", got)
|
||||
}
|
||||
second := assertDNSCanaryRecord(t, canary, 2)
|
||||
if !second.FirstTrigger.Equal(first.FirstTrigger) || second.LatestTrigger.Before(first.LatestTrigger) {
|
||||
t.Error("repeat query changed the first trigger or moved the latest trigger backwards")
|
||||
}
|
||||
assertDNSCanaryEvents(t, collectDNSCanaryEvents(t, events, false), canary, 0)
|
||||
}
|
||||
|
||||
func TestDNSCanaryC2Routing(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
registered bool
|
||||
enabled bool
|
||||
}{
|
||||
{name: "unknown_name", enabled: true},
|
||||
{name: "disabled_detection", registered: true},
|
||||
{name: "registered_name_precedes_c2", registered: true, enabled: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server, parent, domain, checksum := newDNSCanaryNOP(t)
|
||||
var canary *models.DNSCanary
|
||||
if tt.registered {
|
||||
canary = createDNSCanaryRecord(t, domain)
|
||||
}
|
||||
events := subscribeDNSCanaryEvents(t)
|
||||
lookups := countDNSCanaryLookups(t)
|
||||
response := queryDNSCanary(t, server, parent, domain, tt.enabled)
|
||||
wantLookups := int64(0)
|
||||
if tt.enabled {
|
||||
wantLookups = 1
|
||||
}
|
||||
if got := lookups.Load(); got != wantLookups {
|
||||
t.Fatalf("performed %d canary lookups, want %d", got, wantLookups)
|
||||
}
|
||||
if tt.registered && tt.enabled {
|
||||
assertDNSCanaryResponse(t, response)
|
||||
assertDNSCanaryRecord(t, canary, 1)
|
||||
assertDNSCanaryEvents(t, collectDNSCanaryEvents(t, events, false), canary, 1)
|
||||
return
|
||||
}
|
||||
assertDNSCanaryNOP(t, response, checksum)
|
||||
assertDNSCanaryEvents(t, collectDNSCanaryEvents(t, events, false), canary, 0)
|
||||
if tt.registered {
|
||||
assertDNSCanaryRecord(t, canary, 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSCanaryFailedTriggerDoesNotPublish(t *testing.T) {
|
||||
const parent = "canary-failure.invalid."
|
||||
canary := createDNSCanaryRecord(t, models.NewUUID().String()+"."+parent)
|
||||
events := subscribeDNSCanaryEvents(t)
|
||||
reject := rejectDNSCanaryUpdates(t)
|
||||
server := newTestDNSServer()
|
||||
|
||||
assertDNSCanaryResponse(t, queryDNSCanary(t, server, parent, canary.Domain, true))
|
||||
assertDNSCanaryRecord(t, canary, 0)
|
||||
assertDNSCanaryEvents(t, collectDNSCanaryEvents(t, events, false), canary, 0)
|
||||
|
||||
reject.Store(false)
|
||||
assertDNSCanaryResponse(t, queryDNSCanary(t, server, parent, canary.Domain, true))
|
||||
assertDNSCanaryRecord(t, canary, 1)
|
||||
assertDNSCanaryEvents(t, collectDNSCanaryEvents(t, events, false), canary, 1)
|
||||
}
|
||||
|
||||
func createDNSCanaryRecord(t *testing.T, domain string) *models.DNSCanary {
|
||||
t.Helper()
|
||||
canary := &models.DNSCanary{ImplantName: "dns-canary-" + models.NewUUID().String(), Domain: domain}
|
||||
if err := db.Session().Create(canary).Error; err != nil {
|
||||
t.Fatalf("create canary: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Session().Where("id = ?", canary.ID).Delete(&models.DNSCanary{}).Error; err != nil {
|
||||
t.Errorf("delete canary: %v", err)
|
||||
}
|
||||
})
|
||||
return canary
|
||||
}
|
||||
|
||||
func assertDNSCanaryRecord(t *testing.T, canary *models.DNSCanary, count uint32) models.DNSCanary {
|
||||
t.Helper()
|
||||
var stored models.DNSCanary
|
||||
if err := db.Session().Where("id = ?", canary.ID).First(&stored).Error; err != nil {
|
||||
t.Fatalf("reload canary: %v", err)
|
||||
}
|
||||
if stored.Count != count || stored.Triggered != (count > 0) {
|
||||
t.Fatalf("stored count=%d triggered=%v, want count=%d triggered=%v", stored.Count, stored.Triggered, count, count > 0)
|
||||
}
|
||||
if stored.FirstTrigger.IsZero() != (count == 0) || stored.LatestTrigger.IsZero() != (count == 0) {
|
||||
t.Error("trigger timestamps do not match the persisted count")
|
||||
}
|
||||
return stored
|
||||
}
|
||||
|
||||
func countDNSCanaryLookups(t *testing.T) *atomic.Int64 {
|
||||
t.Helper()
|
||||
var count atomic.Int64
|
||||
name := "test:count-dns-canary-lookups-" + models.NewUUID().String()
|
||||
if err := db.Client.Callback().Query().Before("gorm:query").Register(name, func(tx *gorm.DB) {
|
||||
if tx.Statement.Table == "dns_canaries" {
|
||||
count.Add(1)
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatalf("register canary query counter: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Client.Callback().Query().Remove(name) })
|
||||
return &count
|
||||
}
|
||||
|
||||
func rejectDNSCanaryUpdates(t *testing.T) *atomic.Bool {
|
||||
t.Helper()
|
||||
var reject atomic.Bool
|
||||
reject.Store(true)
|
||||
name := "test:reject-dns-canary-updates-" + models.NewUUID().String()
|
||||
if err := db.Client.Callback().Update().Before("gorm:update").Register(name, func(tx *gorm.DB) {
|
||||
if reject.Load() && tx.Statement.Table == "dns_canaries" {
|
||||
_ = tx.AddError(errors.New("injected DNS canary update failure"))
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatalf("register canary update failure: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Client.Callback().Update().Remove(name) })
|
||||
return &reject
|
||||
}
|
||||
|
||||
func subscribeDNSCanaryEvents(t *testing.T) chan core.Event {
|
||||
t.Helper()
|
||||
events := core.EventBroker.Subscribe()
|
||||
t.Cleanup(func() { core.EventBroker.Unsubscribe(events) })
|
||||
// Subscription registration is asynchronous. Complete a broker round trip
|
||||
// before sending requests so the first canary event cannot be missed.
|
||||
collectDNSCanaryEvents(t, events, true)
|
||||
return events
|
||||
}
|
||||
|
||||
func collectDNSCanaryEvents(t *testing.T, events <-chan core.Event, subscribing bool) []core.Event {
|
||||
t.Helper()
|
||||
marker := core.Event{EventType: "dns-canary-test-barrier", Data: []byte(models.NewUUID().String())}
|
||||
core.EventBroker.Publish(marker)
|
||||
timer := time.NewTimer(5 * time.Second)
|
||||
defer timer.Stop()
|
||||
ticker := time.NewTicker(time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
var canaries []core.Event
|
||||
for {
|
||||
select {
|
||||
case event := <-events:
|
||||
if event.EventType == marker.EventType && string(event.Data) == string(marker.Data) {
|
||||
return canaries
|
||||
}
|
||||
if event.EventType == consts.CanaryEvent {
|
||||
canaries = append(canaries, event)
|
||||
}
|
||||
case <-ticker.C:
|
||||
if subscribing {
|
||||
marker.Data = []byte(models.NewUUID().String())
|
||||
core.EventBroker.Publish(marker)
|
||||
}
|
||||
case <-timer.C:
|
||||
t.Fatal("timed out waiting for DNS canary event barrier")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertDNSCanaryEvents(t *testing.T, events []core.Event, canary *models.DNSCanary, count int) {
|
||||
t.Helper()
|
||||
if len(events) != count {
|
||||
t.Fatalf("received %d canary events, want %d", len(events), count)
|
||||
}
|
||||
for _, event := range events {
|
||||
if event.Session == nil || event.Session.Name != canary.ImplantName || string(event.Data) != canary.Domain {
|
||||
t.Errorf("unexpected canary event: %+v", event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newDNSCanaryNOP(t *testing.T) (*SliverDNSServer, string, string, uint32) {
|
||||
t.Helper()
|
||||
server := newTestDNSServer()
|
||||
const sessionID = uint32(0x123456)
|
||||
server.sessions.Store(sessionID, newTestDNSSession(sessionID))
|
||||
parent := models.NewUUID().String() + ".canary-c2.invalid."
|
||||
msg := &dnspb.DNSMessage{Type: dnspb.DNSMessageType_NOP, ID: (1 << 24) | sessionID, Data: []byte("canary-routing")}
|
||||
raw, err := proto.Marshal(msg)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal DNS NOP: %v", err)
|
||||
}
|
||||
domain, err := encodeMessageDomain("."+parent, msg)
|
||||
if err != nil {
|
||||
t.Fatalf("encode DNS NOP: %v", err)
|
||||
}
|
||||
return server, parent, domain, crc32.ChecksumIEEE(raw)
|
||||
}
|
||||
|
||||
func queryDNSCanary(t *testing.T, server *SliverDNSServer, parent, domain string, enabled bool) *dns.Msg {
|
||||
t.Helper()
|
||||
req := new(dns.Msg)
|
||||
req.SetQuestion(domain, dns.TypeA)
|
||||
writer := &canaryDNSResponseWriter{}
|
||||
server.HandleDNSRequest([]string{parent}, enabled, writer, req)
|
||||
if writer.response == nil {
|
||||
t.Fatal("DNS handler did not write a response")
|
||||
}
|
||||
return writer.response
|
||||
}
|
||||
|
||||
func assertDNSCanaryResponse(t *testing.T, response *dns.Msg) {
|
||||
t.Helper()
|
||||
if response.Rcode != dns.RcodeNameError || !response.Authoritative {
|
||||
t.Fatalf("canary response rcode=%d authoritative=%v, want authoritative NXDOMAIN", response.Rcode, response.Authoritative)
|
||||
}
|
||||
}
|
||||
|
||||
func assertDNSCanaryNOP(t *testing.T, response *dns.Msg, checksum uint32) {
|
||||
t.Helper()
|
||||
if response.Rcode != dns.RcodeSuccess || len(response.Answer) != 1 {
|
||||
t.Fatalf("NOP response rcode=%d answers=%d, want success with one answer", response.Rcode, len(response.Answer))
|
||||
}
|
||||
answer, ok := response.Answer[0].(*dns.A)
|
||||
if !ok || len(answer.A) != 4 {
|
||||
t.Fatalf("unexpected DNS NOP answer: %v", response.Answer[0])
|
||||
}
|
||||
if got := binary.LittleEndian.Uint32(answer.A); got != checksum {
|
||||
t.Fatalf("NOP checksum=%d, want %d", got, checksum)
|
||||
}
|
||||
}
|
||||
|
||||
type canaryDNSResponseWriter struct {
|
||||
response *dns.Msg
|
||||
}
|
||||
|
||||
func (w *canaryDNSResponseWriter) LocalAddr() net.Addr { return &net.UDPAddr{} }
|
||||
func (w *canaryDNSResponseWriter) RemoteAddr() net.Addr { return &net.UDPAddr{} }
|
||||
func (w *canaryDNSResponseWriter) Close() error { return nil }
|
||||
func (w *canaryDNSResponseWriter) TsigStatus() error { return nil }
|
||||
func (w *canaryDNSResponseWriter) TsigTimersOnly(bool) {}
|
||||
func (w *canaryDNSResponseWriter) Hijack() {}
|
||||
|
||||
func (w *canaryDNSResponseWriter) WriteMsg(msg *dns.Msg) error {
|
||||
w.response = msg.Copy()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *canaryDNSResponseWriter) Write(data []byte) (int, error) {
|
||||
msg := new(dns.Msg)
|
||||
if err := msg.Unpack(data); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
w.response = msg
|
||||
return len(data), nil
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package c2
|
||||
|
||||
/*
|
||||
Sliver Implant Framework
|
||||
Copyright (C) 2021 Bishop Fox
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// import (
|
||||
// "bytes"
|
||||
// "fmt"
|
||||
// insecureRand "math/rand"
|
||||
// "net/http"
|
||||
// "net/http/httptest"
|
||||
// "net/url"
|
||||
// "testing"
|
||||
|
||||
// implantCrypto "github.com/bishopfox/sliver/implant/sliver/cryptography"
|
||||
// implantEncoders "github.com/bishopfox/sliver/implant/sliver/encoders"
|
||||
// implantTransports "github.com/bishopfox/sliver/implant/sliver/transports/httpclient"
|
||||
// "github.com/bishopfox/sliver/protobuf/sliverpb"
|
||||
// "github.com/bishopfox/sliver/server/configs"
|
||||
// "github.com/bishopfox/sliver/server/cryptography"
|
||||
// "google.golang.org/protobuf/proto"
|
||||
// )
|
||||
|
||||
// func TestStartSessionHandler(t *testing.T) {
|
||||
|
||||
// implantTransports.SetNonceQueryArgChars("abcdedfghijklmnopqrstuvwxyz")
|
||||
|
||||
// server, err := StartHTTPListener(&HTTPServerConfig{
|
||||
// Addr: "127.0.0.1:8888",
|
||||
// Secure: false,
|
||||
// EnforceOTP: true,
|
||||
// })
|
||||
// if err != nil {
|
||||
// t.Fatalf("Listener failed to start %s", err)
|
||||
// return
|
||||
// }
|
||||
|
||||
// client := implantTransports.SliverHTTPClient{}
|
||||
// baseURL := &url.URL{
|
||||
// Scheme: "http",
|
||||
// Host: "127.0.0.1:8888",
|
||||
// Path: fmt.Sprintf("/test/foo.%s", c2Config.ImplantConfig.StartSessionFileExt),
|
||||
// }
|
||||
// nonce, encoder := implantEncoders.RandomEncoder(0)
|
||||
// testURL := client.NonceQueryArgument(baseURL, nonce)
|
||||
// testURL = client.OTPQueryArgument(testURL, implantCrypto.GetOTPCode())
|
||||
|
||||
// // Generate key exchange request
|
||||
// sKey := cryptography.RandomKey()
|
||||
// httpSessionInit := &sliverpb.HTTPSessionInit{Key: sKey[:]}
|
||||
// data, _ := proto.Marshal(httpSessionInit)
|
||||
// encryptedSessionInit, err := implantCrypto.ECCEncryptToServer(data)
|
||||
// if err != nil {
|
||||
// t.Fatalf("Failed to encrypt session init %s", err)
|
||||
// }
|
||||
// payload, _ := encoder.Encode(encryptedSessionInit)
|
||||
// body := bytes.NewReader(payload)
|
||||
|
||||
// validReq := httptest.NewRequest(http.MethodPost, testURL.String(), body)
|
||||
// t.Logf("[http] req request uri: '%v'", validReq.RequestURI)
|
||||
// rr := httptest.NewRecorder()
|
||||
// server.HTTPServer.Handler.ServeHTTP(rr, validReq)
|
||||
// if status := rr.Code; status != http.StatusOK {
|
||||
// t.Fatalf("handler returned wrong status code: got %d want %d", status, http.StatusOK)
|
||||
// }
|
||||
|
||||
// }
|
||||
|
||||
// func TestGetOTPFromURL(t *testing.T) {
|
||||
|
||||
// implantTransports.SetNonceQueryArgChars("abcdedfghijklmnopqrstuvwxyz")
|
||||
|
||||
// client := implantTransports.SliverHTTPClient{}
|
||||
// for i := 0; i < 100; i++ {
|
||||
// baseURL := &url.URL{
|
||||
// Scheme: "http",
|
||||
// Host: "127.0.0.1:8888",
|
||||
// Path: "/test/foo.txt",
|
||||
// }
|
||||
// value := fmt.Sprintf("%d", insecureRand.Intn(99999999))
|
||||
// testURL := client.OTPQueryArgument(baseURL, value)
|
||||
// urlValue, err := getOTPFromURL(testURL)
|
||||
// if err != nil {
|
||||
// t.Fatal(err)
|
||||
// }
|
||||
// if urlValue != value {
|
||||
// t.Fatalf("Mismatched OTP values %s (%s != %s)", testURL.String(), value, urlValue)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// func TestGetNonceFromURL(t *testing.T) {
|
||||
|
||||
// implantTransports.SetNonceQueryArgChars("abcdedfghijklmnopqrstuvwxyz")
|
||||
|
||||
// client := implantTransports.SliverHTTPClient{}
|
||||
// for i := 0; i < 100; i++ {
|
||||
// baseURL := &url.URL{
|
||||
// Scheme: "http",
|
||||
// Host: "127.0.0.1:8888",
|
||||
// Path: "/test/foo.txt",
|
||||
// }
|
||||
// nonce, encoder := implantEncoders.RandomEncoder(0)
|
||||
// testURL := client.NonceQueryArgument(baseURL, nonce)
|
||||
// t.Log(testURL.String())
|
||||
// urlNonce, err := getNonceFromURL(testURL)
|
||||
// if err != nil {
|
||||
// t.Errorf("Nonce '%d' triggered error from %#v", nonce, encoder)
|
||||
// t.Fatal(err)
|
||||
// }
|
||||
// if urlNonce != nonce {
|
||||
// t.Fatalf("Mismatched encoder nonces %s (%d != %d)", testURL.String(), nonce, urlNonce)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// func TestGetNonceFromURLWithCustomQueryArgs(t *testing.T) {
|
||||
// for j := 0; j < 100; j++ {
|
||||
|
||||
// queryArgs := randomArgs(1)
|
||||
// implantTransports.SetNonceQueryArgChars(queryArgs)
|
||||
// t.Logf("Using query args: %s", queryArgs)
|
||||
|
||||
// client := implantTransports.SliverHTTPClient{}
|
||||
// for i := 0; i < 10; i++ {
|
||||
// baseURL := &url.URL{
|
||||
// Scheme: "http",
|
||||
// Host: "127.0.0.1:8888",
|
||||
// Path: "/test/foo.txt",
|
||||
// }
|
||||
// nonce, encoder := implantEncoders.RandomEncoder(0)
|
||||
// testURL := client.NonceQueryArgument(baseURL, nonce)
|
||||
// t.Log(testURL.String())
|
||||
// urlNonce, err := getNonceFromURL(testURL)
|
||||
// if err != nil {
|
||||
// t.Errorf("Nonce '%d' triggered error from %#v", nonce, encoder)
|
||||
// t.Fatal(err)
|
||||
// }
|
||||
// if urlNonce != nonce {
|
||||
// t.Fatalf("Mismatched encoder nonces %s (%d != %d)", testURL.String(), nonce, urlNonce)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// func randomArgs(min int) string {
|
||||
// var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*()")
|
||||
// size := insecureRand.Intn(25) + min
|
||||
// b := make(map[rune]interface{}, size)
|
||||
// for len(b) <= size {
|
||||
// b[letters[insecureRand.Intn(len(letters))]] = nil
|
||||
// }
|
||||
// var keys string
|
||||
// for k := range b {
|
||||
// keys += string(k)
|
||||
// }
|
||||
// return keys
|
||||
// }
|
||||
@@ -465,48 +465,16 @@ func TestRegistryRevokeSessionIsScopedAndIdempotent(t *testing.T) {
|
||||
func TestRegistryGeneratesUniqueOpaqueIDs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
firstBytes := bytes.Repeat([]byte{0xff}, authorizationIDBytes)
|
||||
secondBytes := bytes.Repeat([]byte{0x42}, authorizationIDBytes)
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
random []byte
|
||||
wantError bool
|
||||
wantRemaining int
|
||||
}{
|
||||
{name: "distinct IDs", random: secondBytes},
|
||||
{name: "retry collision", random: bytes.Join([][]byte{firstBytes, secondBytes}, nil)},
|
||||
{
|
||||
name: "exhaust collisions",
|
||||
random: bytes.Join([][]byte{bytes.Repeat(firstBytes, maxAuthorizationIDAttempts), secondBytes}, nil),
|
||||
wantError: true,
|
||||
wantRemaining: authorizationIDBytes,
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
random := bytes.NewReader(bytes.Join([][]byte{firstBytes, test.random}, nil))
|
||||
registry := newRegistry(random)
|
||||
firstID, err := registry.Begin("session", "127.0.0.1:8080", 0)
|
||||
must.NoError(t, err)
|
||||
assert.Len(t, firstID, 43)
|
||||
assert.False(t, strings.ContainsAny(firstID.String(), "+/="))
|
||||
|
||||
secondID, err := registry.Begin("session", "127.0.0.1:8081", 0)
|
||||
if test.wantError {
|
||||
must.ErrorIs(t, err, ErrAuthorizationIDGeneration)
|
||||
assert.Empty(t, secondID)
|
||||
assert.Len(t, registry.List("session"), 1)
|
||||
} else {
|
||||
must.NoError(t, err)
|
||||
assert.NotEqual(t, firstID, secondID)
|
||||
assert.Len(t, secondID, 43)
|
||||
assert.False(t, strings.ContainsAny(secondID.String(), "+/="))
|
||||
assert.Len(t, registry.List("session"), 2)
|
||||
}
|
||||
assert.Equal(t, test.wantRemaining, random.Len())
|
||||
first, ok := registry.Lookup("session", firstID)
|
||||
must.True(t, ok)
|
||||
assert.Equal(t, "127.0.0.1:8080", first.Address)
|
||||
})
|
||||
registry := NewRegistry()
|
||||
seen := map[AuthorizationID]struct{}{}
|
||||
for index := 0; index < 256; index++ {
|
||||
id, err := registry.Begin("session", "127.0.0.1:8080", 0)
|
||||
must.NoError(t, err)
|
||||
assert.Len(t, id, 43)
|
||||
assert.False(t, strings.ContainsAny(id.String(), "+/="))
|
||||
_, duplicate := seen[id]
|
||||
assert.False(t, duplicate)
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -516,6 +484,13 @@ func (errorReader) Read(_ []byte) (int, error) {
|
||||
return 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
|
||||
func TestAuthorizationErrorsAreComparable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := canonicalizeAddress("invalid")
|
||||
assert.True(t, errors.Is(err, ErrInvalidForwardAddress))
|
||||
}
|
||||
|
||||
func acquirePlanForTest(registry *Registry, sessionID string, id AuthorizationID) (dialPlan, error) {
|
||||
plan, _, reservation, err := registry.reserve(sessionID, id, "")
|
||||
if err == nil {
|
||||
|
||||
@@ -134,27 +134,26 @@ func TestAgeKeyExTamper(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tamperedCiphertext := bytes.Clone(allCiphertext[32:])
|
||||
tamperedCiphertext[len(tamperedCiphertext)-1] ^= 0xFF
|
||||
// Tamper with the ciphertext
|
||||
ciphertext := allCiphertext[32:]
|
||||
ciphertext[insecureRand.Intn(len(ciphertext))] ^= 0xFF
|
||||
_, err = AgeKeyExFromImplant(
|
||||
serverAgeKeyPair.Private,
|
||||
implantPeerAgeKeyPair.Private,
|
||||
ciphertext,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
ciphertext []byte
|
||||
}{
|
||||
{name: "tampered ciphertext", ciphertext: tamperedCiphertext},
|
||||
// Retaining the public key hash adds an invalid header to otherwise valid ciphertext.
|
||||
{name: "invalid header", ciphertext: bytes.Clone(allCiphertext)},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := AgeKeyExFromImplant(
|
||||
serverAgeKeyPair.Private,
|
||||
implantPeerAgeKeyPair.Private,
|
||||
test.ciphertext,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected key exchange decryption to reject invalid ciphertext")
|
||||
}
|
||||
})
|
||||
// Leave an invalid header with valid ciphertext
|
||||
_, err = AgeKeyExFromImplant(
|
||||
serverAgeKeyPair.Private,
|
||||
implantPeerAgeKeyPair.Private,
|
||||
allCiphertext,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/bishopfox/sliver/server/db/models"
|
||||
)
|
||||
|
||||
func TestCanaryByDomain(t *testing.T) {
|
||||
canary := &models.DNSCanary{
|
||||
ImplantName: "canary_lookup_" + models.NewUUID().String(),
|
||||
Domain: "lookup-" + models.NewUUID().String() + ".Canary.Example.",
|
||||
}
|
||||
if err := Session().Create(canary).Error; err != nil {
|
||||
t.Fatalf("create canary: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := Session().Delete(canary).Error; err != nil {
|
||||
t.Errorf("delete canary: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
for _, domain := range []string{
|
||||
canary.Domain,
|
||||
strings.ToLower(canary.Domain),
|
||||
strings.ToUpper(canary.Domain),
|
||||
" " + strings.TrimSuffix(canary.Domain, ".") + " ",
|
||||
} {
|
||||
t.Run(domain, func(t *testing.T) {
|
||||
found, err := CanaryByDomain(domain)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup canary: %v", err)
|
||||
}
|
||||
if found.ID != canary.ID.String() || found.Domain != canary.Domain {
|
||||
t.Errorf("lookup returned %v, want ID %s and domain %s", found, canary.ID, canary.Domain)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanaryByDomainNotFound(t *testing.T) {
|
||||
for _, domain := range []string{"", " \t", "missing-" + models.NewUUID().String() + ".invalid."} {
|
||||
t.Run(domain, func(t *testing.T) {
|
||||
found, err := CanaryByDomain(domain)
|
||||
if !errors.Is(err, ErrRecordNotFound) || found != nil {
|
||||
t.Fatalf("lookup returned (%v, %v), want (nil, record not found)", found, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+3
-16
@@ -1,4 +1,3 @@
|
||||
// Package db provides persistence and queries for Sliver server state.
|
||||
package db
|
||||
|
||||
/*
|
||||
@@ -743,25 +742,13 @@ func ListCanaries() ([]*clientpb.DNSCanary, error) {
|
||||
|
||||
// CanaryByDomain - Check if a canary exists
|
||||
func CanaryByDomain(domain string) (*clientpb.DNSCanary, error) {
|
||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||
if domain == "" {
|
||||
if len(domain) < 1 {
|
||||
return nil, ErrRecordNotFound
|
||||
}
|
||||
if !strings.HasSuffix(domain, ".") {
|
||||
domain += "."
|
||||
}
|
||||
dbSession := Session()
|
||||
canary := models.DNSCanary{}
|
||||
// Older builds may have stored mixed-case parent domains. DNS names are
|
||||
// case-insensitive, so normalize both sides without rewriting those records.
|
||||
result := dbSession.Where("LOWER(domain) = ?", domain).Limit(1).Find(&canary)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return nil, ErrRecordNotFound
|
||||
}
|
||||
return canary.ToProtobuf(), nil
|
||||
err := dbSession.Where(&models.DNSCanary{Domain: domain}).First(&canary).Error
|
||||
return canary.ToProtobuf(), err
|
||||
}
|
||||
|
||||
// WebsiteByName - Get website by name
|
||||
|
||||
@@ -48,7 +48,6 @@ func (c *DNSCanary) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
// ToProtobuf - Converts to protobuf object
|
||||
func (c *DNSCanary) ToProtobuf() *clientpb.DNSCanary {
|
||||
return &clientpb.DNSCanary{
|
||||
ID: c.ID.String(),
|
||||
ImplantName: c.ImplantName,
|
||||
Domain: c.Domain,
|
||||
Triggered: c.Triggered,
|
||||
|
||||
@@ -19,10 +19,13 @@ package models
|
||||
*/
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
uuid "uuid"
|
||||
|
||||
gosqlite "github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
@@ -86,6 +89,9 @@ func TestUUIDSQLAndJSONEncoding(t *testing.T) {
|
||||
want := NewUUID()
|
||||
standard := want.Standard()
|
||||
|
||||
var _ sql.Scanner = (*UUID)(nil)
|
||||
var _ driver.Valuer = UUID{}
|
||||
|
||||
value, err := want.Value()
|
||||
if err != nil {
|
||||
t.Fatalf("Value: %v", err)
|
||||
@@ -182,3 +188,11 @@ func TestUUIDGORMRoundTrip(t *testing.T) {
|
||||
t.Fatalf("stored UUID = %q, want canonical %q", storedID, want.ID.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUUIDMatchesStandardLibrary(t *testing.T) {
|
||||
standard := uuid.NewV4()
|
||||
model := UUIDFrom(standard)
|
||||
if model.Standard() != standard {
|
||||
t.Fatalf("Standard = %q, want %q", model.Standard(), standard)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package wasmsqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSQLiteVersion(t *testing.T) {
|
||||
var version string
|
||||
|
||||
db, err := sql.Open(DriverName, ":memory:")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
row := db.QueryRow("select sqlite_version()")
|
||||
if row.Scan(&version) != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
t.Log(version)
|
||||
}
|
||||
@@ -20,65 +20,206 @@ package encoders
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"crypto/rand"
|
||||
insecureRand "math/rand"
|
||||
"testing"
|
||||
|
||||
implantEncoders "github.com/bishopfox/sliver/implant/sliver/encoders"
|
||||
util "github.com/bishopfox/sliver/util/encoders"
|
||||
)
|
||||
|
||||
func TestEncoderCompatibility(t *testing.T) {
|
||||
const (
|
||||
sampleSizeMax = 8192
|
||||
)
|
||||
|
||||
func randomDataRandomSize(maxSize int) []byte {
|
||||
buf := make([]byte, insecureRand.Intn(maxSize))
|
||||
rand.Read(buf)
|
||||
return buf
|
||||
}
|
||||
|
||||
func TestCompatibilityBase64(t *testing.T) {
|
||||
for i := 0; i < 100; i++ {
|
||||
sample := randomDataRandomSize(sampleSizeMax)
|
||||
output, _ := Base64.Encode(sample)
|
||||
data, err := implantEncoders.Base64.Decode(output)
|
||||
if err != nil {
|
||||
t.Error("Failed to encode/decode sample data into base64")
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(sample, data) {
|
||||
t.Errorf("sample does not match returned\n%#v != %#v", sample, data)
|
||||
}
|
||||
|
||||
sample2 := randomDataRandomSize(sampleSizeMax)
|
||||
output, _ = implantEncoders.Base64.Encode(sample2)
|
||||
data, err = Base64.Decode(output)
|
||||
if err != nil {
|
||||
t.Error("Failed to encode/decode sample data into base64")
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(sample2, data) {
|
||||
t.Errorf("sample2 does not match returned\n%#v != %#v", sample2, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompatibilityBase58(t *testing.T) {
|
||||
for i := 0; i < 100; i++ {
|
||||
sample := randomDataRandomSize(sampleSizeMax)
|
||||
output, _ := Base58.Encode(sample)
|
||||
data, err := implantEncoders.Base58.Decode(output)
|
||||
if err != nil {
|
||||
t.Error("Failed to encode/decode sample data into base58")
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(sample, data) {
|
||||
t.Errorf("sample does not match returned\n%#v != %#v", sample, data)
|
||||
}
|
||||
|
||||
sample2 := randomDataRandomSize(sampleSizeMax)
|
||||
output, _ = Base58.Encode(sample2)
|
||||
data, err = implantEncoders.Base58.Decode(output)
|
||||
if err != nil {
|
||||
t.Error("Failed to encode/decode sample data into base58")
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(sample2, data) {
|
||||
t.Errorf("sample2 does not match returned\n%#v != %#v", sample2, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompatibilityBase32(t *testing.T) {
|
||||
for i := 0; i < 100; i++ {
|
||||
sample := randomDataRandomSize(sampleSizeMax)
|
||||
output, _ := Base32.Encode(sample)
|
||||
data, err := implantEncoders.Base32.Decode(output)
|
||||
if err != nil {
|
||||
t.Error("Failed to encode/decode sample data into base32")
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(sample, data) {
|
||||
t.Errorf("sample does not match returned\n%#v != %#v", sample, data)
|
||||
}
|
||||
|
||||
sample2 := randomDataRandomSize(sampleSizeMax)
|
||||
output, _ = Base32.Encode(sample2)
|
||||
data, err = implantEncoders.Base32.Decode(output)
|
||||
if err != nil {
|
||||
t.Error("Failed to encode/decode sample data into base32")
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(sample2, data) {
|
||||
t.Errorf("sample2 does not match returned\n%#v != %#v", sample2, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompatibilityEnglish(t *testing.T) {
|
||||
|
||||
util.SetEnglishDictionary(getTestEnglishDictionary())
|
||||
|
||||
encoders := []struct {
|
||||
name string
|
||||
server util.Encoder
|
||||
implant util.Encoder
|
||||
}{
|
||||
{"base64", Base64, implantEncoders.Base64},
|
||||
{"base58", Base58, implantEncoders.Base58},
|
||||
{"base32", Base32, implantEncoders.Base32},
|
||||
{"english", English, implantEncoders.English},
|
||||
{"hex", Hex, implantEncoders.Hex},
|
||||
{"gzip", Gzip, implantEncoders.Gzip},
|
||||
{"png", PNG, implantEncoders.PNG},
|
||||
for i := 0; i < 100; i++ {
|
||||
sample := randomDataRandomSize(sampleSizeMax)
|
||||
output, _ := English.Encode(sample)
|
||||
data, err := implantEncoders.English.Decode(output)
|
||||
if err != nil {
|
||||
t.Error("Failed to encode/decode sample data into english")
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(sample, data) {
|
||||
t.Errorf("sample does not match returned\n%#v != %#v", sample, data)
|
||||
}
|
||||
|
||||
sample2 := randomDataRandomSize(sampleSizeMax)
|
||||
output, _ = implantEncoders.English.Encode(sample2)
|
||||
data, err = English.Decode(output)
|
||||
if err != nil {
|
||||
t.Error("Failed to encode/decode sample data into english")
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(sample2, data) {
|
||||
t.Errorf("sample2 does not match returned\n%#v != %#v", sample2, data)
|
||||
}
|
||||
}
|
||||
for _, encoder := range encoders {
|
||||
t.Run(encoder.name, func(t *testing.T) {
|
||||
directions := []struct {
|
||||
name string
|
||||
encoder util.Encoder
|
||||
decoder util.Encoder
|
||||
}{
|
||||
{"server_to_implant", encoder.server, encoder.implant},
|
||||
{"implant_to_server", encoder.implant, encoder.server},
|
||||
}
|
||||
for _, direction := range directions {
|
||||
t.Run(direction.name, func(t *testing.T) {
|
||||
// Cover encoding block boundaries, every byte value, leading and
|
||||
// trailing zero bytes, and the old random corpus's upper bound.
|
||||
for _, size := range []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 255, 256, 257, 8192} {
|
||||
t.Run(fmt.Sprintf("bytes_%d", size), func(t *testing.T) {
|
||||
payload := make([]byte, size)
|
||||
for i := range payload {
|
||||
payload[i] = byte(i)
|
||||
}
|
||||
encoded, err := direction.encoder.Encode(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("encode: %v", err)
|
||||
}
|
||||
decoded, err := direction.decoder.Decode(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !bytes.Equal(payload, decoded) {
|
||||
t.Fatalf("round trip changed the %d-byte payload (decoded %d bytes)", len(payload), len(decoded))
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCompatibilityHex(t *testing.T) {
|
||||
for i := 0; i < 100; i++ {
|
||||
sample := randomDataRandomSize(sampleSizeMax)
|
||||
output, _ := Hex.Encode(sample)
|
||||
data, err := implantEncoders.Hex.Decode(output)
|
||||
if err != nil {
|
||||
t.Error("Failed to encode/decode sample data into hex")
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(sample, data) {
|
||||
t.Errorf("sample does not match returned\n%#v != %#v", sample, data)
|
||||
}
|
||||
|
||||
sample2 := randomDataRandomSize(sampleSizeMax)
|
||||
output, _ = Hex.Encode(sample2)
|
||||
data, err = implantEncoders.Hex.Decode(output)
|
||||
if err != nil {
|
||||
t.Error("Failed to encode/decode sample data into hex")
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(sample2, data) {
|
||||
t.Errorf("sample2 does not match returned\n%#v != %#v", sample2, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompatibilityGzip(t *testing.T) {
|
||||
for i := 0; i < 100; i++ {
|
||||
sample := randomDataRandomSize(sampleSizeMax)
|
||||
output, _ := Gzip.Encode(sample)
|
||||
data, err := implantEncoders.Gzip.Decode(output)
|
||||
if err != nil {
|
||||
t.Error("Failed to encode/decode sample data into gzip")
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(sample, data) {
|
||||
t.Errorf("sample does not match returned\n%#v != %#v", sample, data)
|
||||
}
|
||||
|
||||
sample2 := randomDataRandomSize(sampleSizeMax)
|
||||
output, _ = Gzip.Encode(sample2)
|
||||
data, err = implantEncoders.Gzip.Decode(output)
|
||||
if err != nil {
|
||||
t.Error("Failed to encode/decode sample data into gzip")
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(sample2, data) {
|
||||
t.Errorf("sample2 does not match returned\n%#v != %#v", sample2, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompatibilityPNG(t *testing.T) {
|
||||
for i := 0; i < 100; i++ {
|
||||
sample := randomDataRandomSize(sampleSizeMax)
|
||||
output, _ := PNG.Encode(sample)
|
||||
data, err := implantEncoders.PNG.Decode(output)
|
||||
if err != nil {
|
||||
t.Error("Failed to encode/decode sample data into png")
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(sample, data) {
|
||||
t.Errorf("sample does not match returned\n%#v != %#v", sample, data)
|
||||
}
|
||||
|
||||
sample2 := randomDataRandomSize(sampleSizeMax)
|
||||
output, _ = PNG.Encode(sample2)
|
||||
data, err = implantEncoders.PNG.Decode(output)
|
||||
if err != nil {
|
||||
t.Error("Failed to encode/decode sample data into png")
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(sample2, data) {
|
||||
t.Errorf("sample2 does not match returned\n%#v != %#v", sample2, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -198,21 +198,6 @@ func appendToLDFlags(ldflags []string, extra string) []string {
|
||||
return []string{joined}
|
||||
}
|
||||
|
||||
// applyCompactExecutableFunctionAlignment removes unnecessary padding between
|
||||
// functions in non-debug obfuscated executables. Go's linker keeps explicit
|
||||
// per-symbol alignments, so functions that require them remain aligned.
|
||||
func applyCompactExecutableFunctionAlignment(config *clientpb.ImplantConfig, ldflags []string) []string {
|
||||
if config.Debug || !config.ObfuscateSymbols {
|
||||
return ldflags
|
||||
}
|
||||
switch config.GOARCH {
|
||||
case "amd64":
|
||||
return appendToLDFlags(ldflags, "-funcalign=1")
|
||||
default:
|
||||
return ldflags
|
||||
}
|
||||
}
|
||||
|
||||
const linuxShellcodeBuildIDLDFlag = "-extldflags=-Wl,--build-id"
|
||||
|
||||
func applyLinuxShellcodeBuildID(ldflags []string) []string {
|
||||
@@ -829,7 +814,6 @@ func SliverExecutable(name string, build *clientpb.ImplantBuild, config *clientp
|
||||
gcFlags = "all=-N -l"
|
||||
ldflags = []string{}
|
||||
}
|
||||
ldflags = applyCompactExecutableFunctionAlignment(config, ldflags)
|
||||
_, err = gogo.GoBuild(*goConfig, pkgPath, dest, "", tags, ldflags, gcFlags, asmFlags)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -864,11 +848,8 @@ func renderSliverGoCode(name string, build *clientpb.ImplantBuild, config *clien
|
||||
|
||||
// srcDir - ~/.sliver/slivers/<os>/<arch>/<name>/src
|
||||
srcDir := filepath.Join(projectGoPathDir, "src")
|
||||
// Extract GOPATH dependency files before rendering the implant.
|
||||
if err := assets.SetupGoPath(srcDir, config.IncludeDNS); err != nil {
|
||||
return "", fmt.Errorf("setup implant source in %s: %w", srcDir, err)
|
||||
}
|
||||
err := util.ChmodR(srcDir, 0600, 0700) // Ensures src code files are writable
|
||||
assets.SetupGoPath(srcDir, config.IncludeDNS) // Extract GOPATH dependency files
|
||||
err := util.ChmodR(srcDir, 0600, 0700) // Ensures src code files are writable
|
||||
if err != nil {
|
||||
buildLog.Errorf("fs perms: %v", err)
|
||||
return "", err
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//go:build sliver_generate
|
||||
|
||||
package generate
|
||||
|
||||
/*
|
||||
|
||||
@@ -21,13 +21,11 @@ package generate
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
"github.com/bishopfox/sliver/server/db"
|
||||
"github.com/bishopfox/sliver/server/db/models"
|
||||
"github.com/bishopfox/sliver/util"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -52,42 +50,11 @@ func canarySubDomain() string {
|
||||
return string(subdomain)
|
||||
}
|
||||
|
||||
// TriggerCanary records a DNS query and reports whether it first triggered the
|
||||
// existing canary. The caller's status may be stale; only its ID is used.
|
||||
func TriggerCanary(canary *clientpb.DNSCanary) (bool, error) {
|
||||
if canary == nil || canary.ID == "" {
|
||||
return false, fmt.Errorf("missing canary ID")
|
||||
}
|
||||
id, err := models.ParseUUID(canary.ID)
|
||||
if err != nil || id == models.NilUUID() {
|
||||
return false, fmt.Errorf("invalid canary ID %q", canary.ID)
|
||||
}
|
||||
|
||||
firstTrigger := false
|
||||
err = db.Session().Transaction(func(tx *gorm.DB) error {
|
||||
count := tx.Model(&models.DNSCanary{}).Where("id = ?", id).
|
||||
UpdateColumn("count", gorm.Expr("count + ?", 1))
|
||||
if count.Error != nil {
|
||||
return count.Error
|
||||
}
|
||||
if count.RowsAffected != 1 {
|
||||
return db.ErrRecordNotFound
|
||||
}
|
||||
|
||||
// The increment locks the existing row before timestamps are sampled,
|
||||
// so waiting queries cannot move LatestTrigger backwards.
|
||||
first := tx.Model(&models.DNSCanary{}).
|
||||
Where("id = ? AND triggered = ?", id, false).
|
||||
Updates(models.DNSCanary{Triggered: true, FirstTrigger: time.Now()})
|
||||
if first.Error != nil {
|
||||
return first.Error
|
||||
}
|
||||
firstTrigger = first.RowsAffected == 1
|
||||
|
||||
return tx.Model(&models.DNSCanary{}).Where("id = ?", id).
|
||||
Updates(models.DNSCanary{LatestTrigger: time.Now()}).Error
|
||||
})
|
||||
return firstTrigger && err == nil, err
|
||||
// UpdateCanary - Update an existing canary
|
||||
func UpdateCanary(canary *clientpb.DNSCanary) error {
|
||||
dbSession := db.Session()
|
||||
result := dbSession.Save(&canary)
|
||||
return result.Error
|
||||
}
|
||||
|
||||
// CanaryGenerator - Holds data related to canary generation
|
||||
@@ -108,7 +75,7 @@ func (g *CanaryGenerator) GenerateCanary() string {
|
||||
// Don't need secure random here
|
||||
index := util.Intn(len(g.ParentDomains))
|
||||
|
||||
parentDomain := strings.ToLower(strings.TrimSpace(g.ParentDomains[index]))
|
||||
parentDomain := g.ParentDomains[index]
|
||||
parentDomain = strings.TrimPrefix(parentDomain, ".")
|
||||
if !strings.HasSuffix(parentDomain, ".") {
|
||||
parentDomain += "." // Ensure we have the FQDN
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
//go:build sliver_generate
|
||||
|
||||
package generate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
"github.com/bishopfox/sliver/server/certs"
|
||||
"github.com/bishopfox/sliver/server/configs"
|
||||
"github.com/bishopfox/sliver/server/db"
|
||||
"github.com/bishopfox/sliver/server/db/models"
|
||||
)
|
||||
|
||||
// Canary domains must remain visible to a binary scanner after the production
|
||||
// compiler and obfuscator run. Checking rendered source alone misses both
|
||||
// literal obfuscation and linker removal of unused type metadata.
|
||||
func TestCanariesSurviveObfuscation(t *testing.T) {
|
||||
const parentDomain = "canary-regression.invalid."
|
||||
certs.SetupCAs()
|
||||
tests := []struct {
|
||||
name string
|
||||
goos string
|
||||
protocol string
|
||||
beacon bool
|
||||
wantCanaries int
|
||||
}{
|
||||
{name: "windows_http_session", goos: "windows", protocol: "http", wantCanaries: 1},
|
||||
{name: "linux_http_beacon", goos: "linux", protocol: "http", beacon: true, wantCanaries: 1},
|
||||
{name: "windows_mtls_session", goos: "windows", protocol: "mtls", wantCanaries: 2},
|
||||
{name: "linux_wireguard_session", goos: "linux", protocol: "wg", wantCanaries: 2},
|
||||
{name: "linux_http_without_canaries", goos: "linux", protocol: "http"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
name := "canary_" + strings.ReplaceAll(models.NewUUID().String(), "-", "")
|
||||
config := &clientpb.ImplantConfig{
|
||||
GOOS: tt.goos,
|
||||
GOARCH: "amd64",
|
||||
Debug: false,
|
||||
ObfuscateSymbols: true,
|
||||
NetGoEnabled: true,
|
||||
IsBeacon: tt.beacon,
|
||||
C2: []*clientpb.ImplantC2{
|
||||
{URL: tt.protocol + "://c2.invalid"},
|
||||
},
|
||||
}
|
||||
if tt.wantCanaries > 0 {
|
||||
config.CanaryDomains = []string{parentDomain}
|
||||
}
|
||||
switch tt.protocol {
|
||||
case "http":
|
||||
config.IncludeHTTP = true
|
||||
case "mtls":
|
||||
config.IncludeMTLS = true
|
||||
case "wg":
|
||||
config.IncludeWG = true
|
||||
config.WGKeyExchangePort = 1234
|
||||
config.WGTcpCommsPort = 5678
|
||||
certs.SetupWGKeys()
|
||||
}
|
||||
|
||||
// Register cleanup before generation so failed builds do not leave
|
||||
// source trees or canary records behind.
|
||||
projectDir := filepath.Join(GetSliversDir(), config.GOOS, config.GOARCH, name)
|
||||
t.Cleanup(func() {
|
||||
if err := os.RemoveAll(projectDir); err != nil {
|
||||
t.Errorf("remove generated project: %v", err)
|
||||
}
|
||||
if err := db.Session().Where("implant_name = ?", name).Delete(&models.DNSCanary{}).Error; err != nil {
|
||||
t.Errorf("remove generated canaries: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
build, err := GenerateConfig(name, config)
|
||||
if err != nil {
|
||||
t.Fatalf("generate implant config: %v", err)
|
||||
}
|
||||
httpConfig := configs.GenerateDefaultHTTPC2Config()
|
||||
binPath, err := SliverExecutable(name, build, config, httpConfig.ImplantConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("build obfuscated implant: %v", err)
|
||||
}
|
||||
binary, err := os.ReadFile(binPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read obfuscated implant: %v", err)
|
||||
}
|
||||
var canaries []models.DNSCanary
|
||||
if err := db.Session().Where("implant_name = ?", name).Find(&canaries).Error; err != nil {
|
||||
t.Fatalf("read generated canaries: %v", err)
|
||||
}
|
||||
assertCanaryBinary(t, binary, canaries, parentDomain, tt.wantCanaries)
|
||||
t.Logf("checked %d registered canaries in %s/%s obfuscated binary (%d bytes)", len(canaries), config.GOOS, config.GOARCH, len(binary))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertCanaryBinary(t *testing.T, binary []byte, canaries []models.DNSCanary, parentDomain string, wantCanaries int) {
|
||||
t.Helper()
|
||||
|
||||
if bytes.Contains(binary, []byte("[[GenerateCanary]]")) {
|
||||
t.Error("unexpanded canary placeholder remains in binary")
|
||||
}
|
||||
if len(canaries) != wantCanaries {
|
||||
t.Fatalf("registered %d canaries, want %d", len(canaries), wantCanaries)
|
||||
}
|
||||
if wantCanaries == 0 {
|
||||
if bytes.Contains(binary, []byte(parentDomain)) {
|
||||
t.Error("build without canaries contains the canary parent domain")
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, canary := range canaries {
|
||||
if canary.Domain == "" || !strings.HasSuffix(canary.Domain, "."+parentDomain) {
|
||||
t.Errorf("unexpected generated canary domain %q", canary.Domain)
|
||||
continue
|
||||
}
|
||||
if !bytes.Contains(binary, []byte(canary.Domain)) {
|
||||
t.Errorf("registered canary %q is absent from the obfuscated binary", canary.Domain)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
package generate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
"github.com/bishopfox/sliver/server/db"
|
||||
"github.com/bishopfox/sliver/server/db/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func createCanaryStateFixture(t *testing.T) *models.DNSCanary {
|
||||
t.Helper()
|
||||
canary := &models.DNSCanary{
|
||||
ImplantName: "canary_state_" + models.NewUUID().String(),
|
||||
Domain: models.NewUUID().String() + ".canary.invalid.",
|
||||
}
|
||||
if err := db.Session().Create(canary).Error; err != nil {
|
||||
t.Fatalf("create canary: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Session().Delete(canary).Error; err != nil {
|
||||
t.Errorf("delete canary: %v", err)
|
||||
}
|
||||
})
|
||||
return canary
|
||||
}
|
||||
|
||||
func readCanaryState(t *testing.T, id models.UUID) models.DNSCanary {
|
||||
t.Helper()
|
||||
var canary models.DNSCanary
|
||||
if err := db.Session().First(&canary, "id = ?", id).Error; err != nil {
|
||||
t.Fatalf("read canary: %v", err)
|
||||
}
|
||||
return canary
|
||||
}
|
||||
|
||||
func assertCanaryTriggerCount(t *testing.T, canary models.DNSCanary, count uint32) {
|
||||
t.Helper()
|
||||
if !canary.Triggered || canary.Count != count {
|
||||
t.Errorf("triggered/count = %v/%d, want true/%d", canary.Triggered, canary.Count, count)
|
||||
}
|
||||
if canary.FirstTrigger.IsZero() || canary.LatestTrigger.Before(canary.FirstTrigger) {
|
||||
t.Errorf("invalid trigger times: first=%v latest=%v", canary.FirstTrigger, canary.LatestTrigger)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerCanaryPersistsRepeatedQueries(t *testing.T) {
|
||||
canary := createCanaryStateFixture(t)
|
||||
// Reuse a stale caller snapshot; persistence must use the database state.
|
||||
snapshot := &clientpb.DNSCanary{ID: canary.ID.String(), Count: 99}
|
||||
first, err := TriggerCanary(snapshot)
|
||||
if err != nil || !first {
|
||||
t.Fatalf("first trigger = (%v, %v), want (true, nil)", first, err)
|
||||
}
|
||||
initial := readCanaryState(t, canary.ID)
|
||||
assertCanaryTriggerCount(t, initial, 1)
|
||||
first, err = TriggerCanary(snapshot)
|
||||
if err != nil || first {
|
||||
t.Fatalf("repeat trigger = (%v, %v), want (false, nil)", first, err)
|
||||
}
|
||||
latest := readCanaryState(t, canary.ID)
|
||||
assertCanaryTriggerCount(t, latest, 2)
|
||||
if !latest.FirstTrigger.Equal(initial.FirstTrigger) || latest.LatestTrigger.Before(initial.LatestTrigger) {
|
||||
t.Error("repeat query changed first trigger time or moved latest trigger backwards")
|
||||
}
|
||||
if latest.Domain != canary.Domain || latest.ImplantName != canary.ImplantName {
|
||||
t.Error("trigger overwrote canary identity from the caller snapshot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerCanaryConcurrentQueries(t *testing.T) {
|
||||
const queries = 12
|
||||
canary := createCanaryStateFixture(t)
|
||||
snapshot := &clientpb.DNSCanary{ID: canary.ID.String()}
|
||||
type result struct {
|
||||
first bool
|
||||
err error
|
||||
}
|
||||
results := make(chan result, queries)
|
||||
start := make(chan struct{})
|
||||
for range queries {
|
||||
go func() {
|
||||
<-start
|
||||
first, err := TriggerCanary(snapshot)
|
||||
results <- result{first: first, err: err}
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
firstTriggers := 0
|
||||
for range queries {
|
||||
got := <-results
|
||||
if got.err != nil {
|
||||
t.Errorf("concurrent trigger: %v", got.err)
|
||||
}
|
||||
if got.first {
|
||||
firstTriggers++
|
||||
}
|
||||
}
|
||||
if firstTriggers != 1 {
|
||||
t.Errorf("first triggers = %d, want 1", firstTriggers)
|
||||
}
|
||||
assertCanaryTriggerCount(t, readCanaryState(t, canary.ID), queries)
|
||||
}
|
||||
|
||||
func TestTriggerCanaryRejectsMissingIdentity(t *testing.T) {
|
||||
canary := createCanaryStateFixture(t)
|
||||
var before int64
|
||||
if err := db.Session().Model(&models.DNSCanary{}).Count(&before).Error; err != nil {
|
||||
t.Fatalf("count canaries: %v", err)
|
||||
}
|
||||
for _, snapshot := range []*clientpb.DNSCanary{
|
||||
nil,
|
||||
{},
|
||||
{ID: "not-a-uuid"},
|
||||
{ID: models.NilUUID().String()},
|
||||
{ID: models.NewUUID().String()},
|
||||
} {
|
||||
first, err := TriggerCanary(snapshot)
|
||||
if err == nil || first {
|
||||
t.Errorf("trigger for %v = (%v, %v), want (false, error)", snapshot, first, err)
|
||||
}
|
||||
}
|
||||
var after int64
|
||||
if err := db.Session().Model(&models.DNSCanary{}).Count(&after).Error; err != nil {
|
||||
t.Fatalf("count canaries: %v", err)
|
||||
}
|
||||
if after != before {
|
||||
t.Errorf("invalid identity inserted canaries: count changed from %d to %d", before, after)
|
||||
}
|
||||
unchanged := readCanaryState(t, canary.ID)
|
||||
if unchanged.Triggered || unchanged.Count != 0 {
|
||||
t.Error("invalid identity changed an existing canary")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerCanaryRollsBackWriteFailure(t *testing.T) {
|
||||
canary := createCanaryStateFixture(t)
|
||||
injectedErr := errors.New("injected canary timestamp write failure")
|
||||
callbackName := "test:canary-trigger-write-" + canary.ID.String()
|
||||
var rejectWrite atomic.Bool
|
||||
rejectWrite.Store(true)
|
||||
if err := db.Client.Callback().Update().Before("gorm:update").Register(callbackName, func(tx *gorm.DB) {
|
||||
update, ok := tx.Statement.Dest.(models.DNSCanary)
|
||||
if rejectWrite.Load() && ok && !update.LatestTrigger.IsZero() {
|
||||
_ = tx.AddError(injectedErr)
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatalf("register write failure: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
rejectWrite.Store(false)
|
||||
if err := db.Client.Callback().Update().Remove(callbackName); err != nil {
|
||||
t.Errorf("remove write failure callback: %v", err)
|
||||
}
|
||||
})
|
||||
first, err := TriggerCanary(&clientpb.DNSCanary{ID: canary.ID.String()})
|
||||
if first || !errors.Is(err, injectedErr) {
|
||||
t.Fatalf("failed trigger = (%v, %v), want (false, injected error)", first, err)
|
||||
}
|
||||
unchanged := readCanaryState(t, canary.ID)
|
||||
if unchanged.Triggered || unchanged.Count != 0 || !unchanged.FirstTrigger.IsZero() || !unchanged.LatestTrigger.IsZero() {
|
||||
t.Error("failed trigger did not roll back count and trigger timestamps")
|
||||
}
|
||||
rejectWrite.Store(false)
|
||||
first, err = TriggerCanary(&clientpb.DNSCanary{ID: canary.ID.String()})
|
||||
if err != nil || !first {
|
||||
t.Fatalf("trigger after rollback = (%v, %v), want (true, nil)", first, err)
|
||||
}
|
||||
assertCanaryTriggerCount(t, readCanaryState(t, canary.ID), 1)
|
||||
}
|
||||
|
||||
func TestGenerateCanaryNormalizesParent(t *testing.T) {
|
||||
for _, parent := range []string{"MiXeD.Example", "MIXED.EXAMPLE.", " \t.MiXeD.Example \t"} {
|
||||
t.Run(parent, func(t *testing.T) {
|
||||
name := "canary_normalization_" + models.NewUUID().String()
|
||||
t.Cleanup(func() {
|
||||
if err := db.Session().Where("implant_name = ?", name).Delete(&models.DNSCanary{}).Error; err != nil {
|
||||
t.Errorf("delete generated canary: %v", err)
|
||||
}
|
||||
})
|
||||
generator := CanaryGenerator{ImplantName: name, ParentDomains: []string{parent}}
|
||||
domain := generator.GenerateCanary()
|
||||
if !strings.HasSuffix(domain, ".mixed.example.") || domain != strings.ToLower(domain) {
|
||||
t.Fatalf("generated domain %q was not normalized", domain)
|
||||
}
|
||||
canary, err := db.CanaryByDomain(domain)
|
||||
if err != nil {
|
||||
t.Fatalf("look up normalized canary: %v", err)
|
||||
}
|
||||
if canary.Domain != domain || canary.ImplantName != name {
|
||||
t.Errorf("stored canary = %v, want domain %s and implant %s", canary, domain, name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
"github.com/bishopfox/sliver/server/gogo"
|
||||
)
|
||||
|
||||
@@ -136,30 +135,3 @@ func TestSanitizeZigForBuild_Windows(t *testing.T) {
|
||||
t.Fatalf("expected -fdebug-prefix-map to be set for zig builds, got CXX=%q", cfg.CXX)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCompactExecutableFunctionAlignment(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
arch string
|
||||
debug bool
|
||||
obfuscate bool
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{name: "amd64", arch: "amd64", obfuscate: true, want: "-funcalign=1"},
|
||||
{name: "arm64 unchanged", arch: "arm64", obfuscate: true, want: ""},
|
||||
{name: "windows gui", arch: "amd64", obfuscate: true, input: "-H=windowsgui", want: "-H=windowsgui -funcalign=1"},
|
||||
{name: "debug", arch: "amd64", debug: true, obfuscate: true, want: ""},
|
||||
{name: "plain", arch: "amd64", want: ""},
|
||||
{name: "unsupported arch", arch: "386", obfuscate: true, want: ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
config := &clientpb.ImplantConfig{GOARCH: tt.arch, Debug: tt.debug, ObfuscateSymbols: tt.obfuscate}
|
||||
got := applyCompactExecutableFunctionAlignment(config, []string{tt.input})
|
||||
if len(got) != 1 || got[0] != tt.want {
|
||||
t.Fatalf("applyCompactExecutableFunctionAlignment() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package generate
|
||||
|
||||
/*
|
||||
Sliver Implant Framework
|
||||
Copyright (C) 2019 Bishop Fox
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@@ -1,5 +1,3 @@
|
||||
//go:build sliver_generate
|
||||
|
||||
package generate
|
||||
|
||||
/*
|
||||
|
||||
+1
-1
@@ -149,7 +149,7 @@ func GarbleCmd(config GoConfig, cwd string, command []string) ([]byte, error) {
|
||||
return nil, fmt.Errorf("%s", fmt.Sprintf("Invalid compiler target: %s", target))
|
||||
}
|
||||
garbleBinPath := goToolPath(config.GOROOT, "garble")
|
||||
garbleFlags := []string{"-seed=random", "-literals", "-literals-shared", "-literals-shared-short", "-tiny"}
|
||||
garbleFlags := []string{"-seed=random", "-literals", "-tiny"}
|
||||
command = append(garbleFlags, command...)
|
||||
cmd := exec.Command(garbleBinPath, command...)
|
||||
cmd.Dir = cwd
|
||||
|
||||
@@ -123,6 +123,40 @@ func TestAllLoot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllLootOf(t *testing.T) {
|
||||
lootStore := GetLootStore()
|
||||
_, err := lootStore.Add(&clientpb.Loot{
|
||||
Name: name1,
|
||||
FileType: clientpb.FileType_BINARY,
|
||||
File: &commonpb.File{
|
||||
Name: name1,
|
||||
Data: data1,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = lootStore.Add(&clientpb.Loot{
|
||||
Name: name2,
|
||||
FileType: clientpb.FileType_TEXT,
|
||||
File: &commonpb.File{
|
||||
Name: name1,
|
||||
Data: []byte("hello world"),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
for _, loot := range lootStore.All().Loot {
|
||||
err = lootStore.Rm(loot.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLootErrors(t *testing.T) {
|
||||
lootStore := GetLootStore()
|
||||
loot, err := lootStore.Add(&clientpb.Loot{
|
||||
|
||||
@@ -48,6 +48,29 @@ func randomData() []byte {
|
||||
return buf
|
||||
}
|
||||
|
||||
func TestAddContent(t *testing.T) {
|
||||
webContent := clientpb.WebContent{
|
||||
Path: "/data1",
|
||||
ContentType: contentType1,
|
||||
Size: uint64(len(data1)),
|
||||
Content: data1,
|
||||
}
|
||||
err := AddContent(website1, &webContent)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
webContent2 := clientpb.WebContent{
|
||||
Path: "/data2",
|
||||
ContentType: contentType2,
|
||||
Size: uint64(len(data2)),
|
||||
Content: data1,
|
||||
}
|
||||
err = AddContent(website2, &webContent2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetContent(t *testing.T) {
|
||||
|
||||
webContent := clientpb.WebContent{
|
||||
|
||||
@@ -165,22 +165,6 @@ CGO_ENABLED=0 go test -c -mod=vendor -trimpath \
|
||||
-implant-modes session,beacon
|
||||
```
|
||||
|
||||
To verify the WASM extension path on a generated, obfuscated implant, use the same server and driver build commands above, then run the focused lifecycle scope. The driver starts a loopback listener and generates fresh session and beacon implants. Both modes register a tiny deterministic gzip-compressed WASI module, confirm inventory, and complete a subsequent `Ping`; the session also executes the module and asserts its distinctive exit code. Beacon execution uses a tunnel handler that the beacon task dispatcher does not route, so the focused run logs that cell as unsupported. For example, on a native Darwin/arm64 host:
|
||||
|
||||
```sh
|
||||
./sliver-comprehensive-e2e \
|
||||
-test.v -test.run=^TestComprehensiveE2E$ -test.timeout=0 \
|
||||
-repo . -server ./sliver-server-e2e \
|
||||
-target-os darwin -target-arch arm64 \
|
||||
-results ./e2e-results -suite-scope wasm \
|
||||
-transports mtls -implant-modes session,beacon \
|
||||
-implant-obfuscate
|
||||
```
|
||||
|
||||
Run the same command natively on `linux/amd64` or `windows/amd64`, changing the target flags and executable suffix on Windows. WASM extensions are unsupported on Linux/arm64 and Windows/arm64. The `wasm` scope writes PASS/FAIL details to the driver log; it does not claim the full comprehensive command matrix passed.
|
||||
|
||||
Pass `-implant-obfuscate` to generate implants with Garble symbol and literal obfuscation during a local run. The driver logs each generated implant size and SHA256, and verifies that amd64 builds outside debug mode used `-funcalign=1` before launching them.
|
||||
|
||||
For a local feature-only run, add either `-suite-scope rportfwd` or `-suite-scope portfwd-socks5`. Both scopes automatically select sessions even if the default `session,beacon` mode list is retained. The latter is intentionally local/Proxmox-only for now: the existing GitHub Actions dispatch input has not been changed to accept it. Its default `base` profile is diagnostic only; add `-tunnel-acceptance-profile proxmox` together with all required range target flags for an acceptance run. In GitHub Actions, `rportfwd` remains the only focused choice on the manually dispatched **Comprehensive e2e Tests** workflow.
|
||||
|
||||
Run the shellcode group with the same server and test binary by changing the selector to `-test.run=^TestShellcodeE2E$` and writing results to a separate directory such as `./shellcode-results`. The default four SGN samples match the workflow; `-shellcode-sgn-samples` can request a higher stress depth. Shellcode aggregation expects the complete four-target workflow matrix; a single local target is useful as a runtime smoke test but intentionally reports the other required targets as `NOT RUN`.
|
||||
|
||||
@@ -35,7 +35,7 @@ func init() {
|
||||
flag.StringVar(&testOptions.resultsDir, "results", "", "directory for JSON and Markdown coverage reports")
|
||||
flag.StringVar(&testOptions.transportCSV, "transports", strings.Join(transportOrder, ","), "comma-separated transports to run (mtls,wg,http)")
|
||||
flag.StringVar(&testOptions.modeCSV, "implant-modes", "session,beacon", "comma-separated implant modes to run (session,beacon)")
|
||||
flag.StringVar(&testOptions.suiteScope, "suite-scope", defaultSuiteScope(), "E2E suite scope (comprehensive, wasm, rportfwd, or portfwd-socks5)")
|
||||
flag.StringVar(&testOptions.suiteScope, "suite-scope", defaultSuiteScope(), "E2E suite scope (comprehensive, rportfwd, or portfwd-socks5)")
|
||||
flag.DurationVar(&testOptions.timeout, "e2e-timeout", 4*time.Hour, "overall comprehensive E2E timeout")
|
||||
flag.DurationVar(&testOptions.startupTimeout, "startup-timeout", 10*time.Minute, "Sliver daemon startup timeout")
|
||||
flag.DurationVar(&testOptions.connectTimeout, "connect-timeout", 5*time.Minute, "implant connection timeout")
|
||||
@@ -51,7 +51,6 @@ func init() {
|
||||
flag.IntVar(&testOptions.rdpCredentialsFD, "tunnel-rdp-credentials-fd", -1, "optional descriptor containing one runtime-only RDP credential and certificate-pin JSON object")
|
||||
flag.StringVar(&testOptions.tunnelAcceptanceProfile, "tunnel-acceptance-profile", tunnelAcceptanceProfileBase, "focused tunnel evidence profile (base or proxmox)")
|
||||
flag.BoolVar(&testOptions.implantDebug, "implant-debug", false, "generate debug implants and capture their transport logs")
|
||||
flag.BoolVar(&testOptions.implantObfuscate, "implant-obfuscate", false, "generate implants with Garble symbol and literal obfuscation")
|
||||
flag.BoolVar(&goroutineLeakProfile, "goroutine-leak-profile", false, "write the goroutine leak profile at comprehensive E2E shutdown")
|
||||
}
|
||||
|
||||
|
||||
+3
-40
@@ -13,7 +13,6 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -36,7 +35,6 @@ import (
|
||||
const (
|
||||
operatorName = "e2e-operator"
|
||||
suiteScopeComprehensive = "comprehensive"
|
||||
suiteScopeWasm = "wasm"
|
||||
suiteScopeRportFwd = "rportfwd"
|
||||
suiteScopePortfwdSocks5 = "portfwd-socks5"
|
||||
tunnelAcceptanceProfileBase = "base"
|
||||
@@ -78,7 +76,6 @@ type options struct {
|
||||
rdpCredentialsFD int
|
||||
tunnelAcceptanceProfile string
|
||||
implantDebug bool
|
||||
implantObfuscate bool
|
||||
}
|
||||
|
||||
type suite struct {
|
||||
@@ -405,9 +402,6 @@ func validateOptions(opts *options) error {
|
||||
if !supported[target] {
|
||||
return fmt.Errorf("target %s is not in the comprehensive E2E matrix", target)
|
||||
}
|
||||
if opts.suiteScope == suiteScopeWasm && (target == "linux/arm64" || target == "windows/arm64") {
|
||||
return fmt.Errorf("WASM extensions are not supported on %s", target)
|
||||
}
|
||||
if runtime.GOOS != opts.targetOS || runtime.GOARCH != opts.targetArch {
|
||||
return fmt.Errorf("E2E driver must run as the implant target: driver is %s/%s, target is %s", runtime.GOOS, runtime.GOARCH, target)
|
||||
}
|
||||
@@ -534,8 +528,8 @@ func validateSocksFuzzOptions(caseCount int, replayCase int) error {
|
||||
|
||||
func normalizeSuiteScope(value string) (string, error) {
|
||||
scope := strings.TrimSpace(value)
|
||||
if scope != suiteScopeComprehensive && scope != suiteScopeWasm && scope != suiteScopeRportFwd && scope != suiteScopePortfwdSocks5 {
|
||||
return "", fmt.Errorf("unknown E2E suite scope %q (want %s, %s, %s, or %s)", scope, suiteScopeComprehensive, suiteScopeWasm, suiteScopeRportFwd, suiteScopePortfwdSocks5)
|
||||
if scope != suiteScopeComprehensive && scope != suiteScopeRportFwd && scope != suiteScopePortfwdSocks5 {
|
||||
return "", fmt.Errorf("unknown E2E suite scope %q (want %s, %s, or %s)", scope, suiteScopeComprehensive, suiteScopeRportFwd, suiteScopePortfwdSocks5)
|
||||
}
|
||||
return scope, nil
|
||||
}
|
||||
@@ -867,7 +861,7 @@ func (s *suite) runImplant(listener *listener, beacon bool) error {
|
||||
GOARCH: s.opts.targetArch,
|
||||
TemplateName: "sliver",
|
||||
Debug: s.opts.implantDebug,
|
||||
ObfuscateSymbols: s.opts.implantObfuscate,
|
||||
ObfuscateSymbols: false,
|
||||
IsBeacon: beacon,
|
||||
BeaconInterval: int64(s.opts.beaconInterval),
|
||||
BeaconJitter: 0,
|
||||
@@ -911,15 +905,6 @@ func (s *suite) runImplant(listener *listener, beacon bool) error {
|
||||
if generated.File == nil || generated.File.Name == "" || len(generated.File.Data) == 0 {
|
||||
return fmt.Errorf("generate %s returned an empty executable", implantName)
|
||||
}
|
||||
if s.opts.implantObfuscate {
|
||||
hash := sha256.Sum256(generated.File.Data)
|
||||
s.t.Logf("Generated obfuscated implant %s: %d bytes, SHA256 %x", implantName, len(generated.File.Data), hash)
|
||||
if s.opts.targetArch == "amd64" && !s.opts.implantDebug {
|
||||
if err := verifyCompactExecutableBuild(filepath.Join(s.serverRoot, "logs", "sliver.json"), implantName); err != nil {
|
||||
return fmt.Errorf("verify compact obfuscated build for %s: %w", implantName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
remoteRoot := filepath.Join(s.workDir, "targets", implantName)
|
||||
if err := createKnownTree(remoteRoot); err != nil {
|
||||
@@ -975,9 +960,6 @@ func (s *suite) runImplant(listener *listener, beacon bool) error {
|
||||
if s.opts.suiteScope == suiteScopeComprehensive {
|
||||
exerciseErrors = appendIfError(exerciseErrors, s.exerciseCommands(target, remoteRoot, listener.transport))
|
||||
}
|
||||
if s.opts.suiteScope == suiteScopeWasm {
|
||||
exerciseErrors = appendIfError(exerciseErrors, s.exerciseWasmExtensionLifecycle(target, listener.transport))
|
||||
}
|
||||
if target.session != nil && s.opts.suiteScope == suiteScopePortfwdSocks5 {
|
||||
exerciseErrors = appendIfError(exerciseErrors, s.exercisePortForward(target, listener.transport))
|
||||
exerciseErrors = appendIfError(exerciseErrors, s.exerciseSocks5(target, listener.transport))
|
||||
@@ -1321,25 +1303,6 @@ func readLogTailBytes(path string, limit int64) string {
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
// verifyCompactExecutableBuild checks the server's recorded compiler command
|
||||
// for the exact generated implant before the E2E driver launches it.
|
||||
func verifyCompactExecutableBuild(logPath, implantName string) error {
|
||||
data, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read compiler log: %w", err)
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if !strings.Contains(line, "garble cmd:") || !strings.Contains(line, implantName) {
|
||||
continue
|
||||
}
|
||||
if !slices.Contains(strings.Fields(line), "-funcalign=1") {
|
||||
return fmt.Errorf("compiler command for %s omitted -funcalign=1", implantName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("compiler command for %s not found", implantName)
|
||||
}
|
||||
|
||||
func readCompilerStderr(path string) string {
|
||||
type compilerLogEntry struct {
|
||||
Message string `json:"msg"`
|
||||
|
||||
@@ -359,27 +359,3 @@ func TestReadCompilerStderrReturnsLatestCompilerMessageOnly(t *testing.T) {
|
||||
t.Fatalf("compiler stderr included unrelated log content: %q", diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyCompactExecutableBuildRequiresExactAlignmentFlag(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
ldflags string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "exact flag", ldflags: "-funcalign=1"},
|
||||
{name: "larger alignment", ldflags: "-funcalign=16", wantErr: true},
|
||||
{name: "missing flag", ldflags: "-s -w", wantErr: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "sliver.json")
|
||||
line := `{"msg":"garble cmd: '/tmp/garble -literals build -ldflags ` + test.ldflags + ` -o /tmp/e2e-test/bin/e2e-test .'"}`
|
||||
if err := os.WriteFile(path, []byte(line), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := verifyCompactExecutableBuild(path, "e2e-test")
|
||||
if (err != nil) != test.wantErr {
|
||||
t.Fatalf("verifyCompactExecutableBuild() error = %v, want error %t", err, test.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/bishopfox/sliver/protobuf/commonpb"
|
||||
"github.com/bishopfox/sliver/protobuf/sliverpb"
|
||||
)
|
||||
|
||||
// wasmExitSeven is a minimal WASI module. Its _start calls
|
||||
// wasi_snapshot_preview1.proc_exit(7). A distinctive exit code proves that
|
||||
// the implant decoded, compiled, and ran the registered module.
|
||||
var wasmExitSeven = [...]byte{
|
||||
0, 97, 115, 109, 1, 0, 0, 0,
|
||||
1, 8, 2, 96, 1, 127, 0, 96, 0, 0,
|
||||
2, 36, 1, 22, 119, 97, 115, 105, 95, 115, 110, 97, 112, 115,
|
||||
104, 111, 116, 95, 112, 114, 101, 118, 105, 101, 119, 49,
|
||||
9, 112, 114, 111, 99, 95, 101, 120, 105, 116, 0, 0,
|
||||
3, 2, 1, 1,
|
||||
7, 10, 1, 6, 95, 115, 116, 97, 114, 116, 0, 1,
|
||||
10, 8, 1, 6, 0, 65, 7, 16, 0, 11,
|
||||
}
|
||||
|
||||
func gzipWasmExitSeven() ([]byte, error) {
|
||||
var compressed bytes.Buffer
|
||||
writer := gzip.NewWriter(&compressed)
|
||||
if _, err := writer.Write(wasmExitSeven[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return compressed.Bytes(), nil
|
||||
}
|
||||
|
||||
func (s *suite) liveWasmStep(target implantTarget, transport, method string, fn func() error) error {
|
||||
started := time.Now()
|
||||
if err := fn(); err != nil {
|
||||
return fmt.Errorf("%s (%s/%s %s/%s, %s): %w", method,
|
||||
s.opts.targetOS, s.opts.targetArch, transport, target.mode(),
|
||||
time.Since(started).Round(time.Millisecond), err)
|
||||
}
|
||||
s.t.Logf("PASS live %s/%s %s %s %s (%s)", s.opts.targetOS,
|
||||
s.opts.targetArch, transport, target.mode(), method,
|
||||
time.Since(started).Round(time.Millisecond))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *suite) exerciseWasmExtensionLifecycle(target implantTarget, transport string) error {
|
||||
const name = "sliver-e2e-exit-seven.wasm"
|
||||
compressed, err := gzipWasmExitSeven()
|
||||
if err != nil {
|
||||
return fmt.Errorf("compress deterministic WASM fixture: %w", err)
|
||||
}
|
||||
if err := s.liveWasmStep(target, transport, "RegisterWasmExtension", func() error {
|
||||
_, err := invokeRPC(s, target, "RegisterWasmExtension", func(ctx context.Context, request *commonpb.Request) (*sliverpb.RegisterWasmExtension, error) {
|
||||
return s.rpc.RegisterWasmExtension(ctx, &sliverpb.RegisterWasmExtensionReq{
|
||||
Name: name, WasmGz: compressed, Request: request,
|
||||
})
|
||||
}, func(response *sliverpb.RegisterWasmExtension) *commonpb.Response {
|
||||
return response.GetResponse()
|
||||
})
|
||||
return err
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.liveWasmStep(target, transport, "ListWasmExtensions", func() error {
|
||||
response, err := invokeRPC(s, target, "ListWasmExtensions", func(ctx context.Context, request *commonpb.Request) (*sliverpb.ListWasmExtensions, error) {
|
||||
return s.rpc.ListWasmExtensions(ctx, &sliverpb.ListWasmExtensionsReq{Request: request})
|
||||
}, func(response *sliverpb.ListWasmExtensions) *commonpb.Response {
|
||||
return response.GetResponse()
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(response.Names) != 1 || response.Names[0] != name {
|
||||
return fmt.Errorf("registered WASM inventory = %v, want [%s]", response.Names, name)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
// Beacon tasks use the standard handler table. WASM execution is a tunnel
|
||||
// handler, so only a session can exercise this execution path today.
|
||||
if target.beacon != nil {
|
||||
s.t.Logf("SKIP live %s/%s %s beacon ExecWasmExtension: beacon tasks do not dispatch tunnel handlers",
|
||||
s.opts.targetOS, s.opts.targetArch, transport)
|
||||
} else if err := s.liveWasmStep(target, transport, "ExecWasmExtension", func() error {
|
||||
response, err := invokeRPC(s, target, "ExecWasmExtension", func(ctx context.Context, request *commonpb.Request) (*sliverpb.ExecWasmExtension, error) {
|
||||
return s.rpc.ExecWasmExtension(ctx, &sliverpb.ExecWasmExtensionReq{
|
||||
Name: name, Request: request,
|
||||
})
|
||||
}, func(response *sliverpb.ExecWasmExtension) *commonpb.Response {
|
||||
return response.GetResponse()
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if response.ExitCode != 7 {
|
||||
return fmt.Errorf("WASM exit code = %d, want 7", response.ExitCode)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.liveWasmStep(target, transport, "Ping after WASM lifecycle", func() error {
|
||||
const nonce = int32(0x5a17c0de)
|
||||
response, err := invokeRPC(s, target, "Ping", func(ctx context.Context, request *commonpb.Request) (*sliverpb.Ping, error) {
|
||||
return s.rpc.Ping(ctx, &sliverpb.Ping{Nonce: nonce, Request: request})
|
||||
}, func(response *sliverpb.Ping) *commonpb.Response {
|
||||
return response.GetResponse()
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if response.Nonce != nonce {
|
||||
return fmt.Errorf("Ping nonce = %d, want %d", response.Nonce, nonce)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -2,7 +2,7 @@ package assets
|
||||
|
||||
const (
|
||||
goVersion = "1.27.3"
|
||||
garbleVersion = "1.27.3b"
|
||||
garbleVersion = "1.27.3"
|
||||
zigVersion = "0.15.2"
|
||||
zigSourceParam = "source=sliver"
|
||||
|
||||
|
||||
@@ -26,45 +26,43 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPNG(t *testing.T) {
|
||||
allBytes := make([]byte, 256)
|
||||
for i := range allBytes {
|
||||
allBytes[i] = byte(i)
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
payload []byte
|
||||
var (
|
||||
imageTests = []struct {
|
||||
Input []byte
|
||||
}{
|
||||
{"empty", nil},
|
||||
{"one_zero", []byte{0}},
|
||||
{"one_escape", []byte{1}},
|
||||
{"partial_pixel", []byte{2, 3}},
|
||||
{"pixel_aligned", []byte("abc")},
|
||||
{"pixel_overflow", []byte("abcde")},
|
||||
{"before_square_boundary", bytes.Repeat([]byte{2}, 11)},
|
||||
{"at_square_boundary", bytes.Repeat([]byte{2}, 12)},
|
||||
{"after_square_boundary", bytes.Repeat([]byte{2}, 13)},
|
||||
{"leading_zero", []byte{0, 1, 2, 3, 4}},
|
||||
{"trailing_zero", []byte{1, 2, 3, 4, 0}},
|
||||
{"adjacent_escapes", []byte{0, 1, 0, 1, 1, 0}},
|
||||
{"all_bytes", allBytes},
|
||||
{"one_megabyte", bytes.Repeat(allBytes, 4096)},
|
||||
{[]byte("abc")}, // byte count on image pixel alignment
|
||||
{[]byte("abcde")}, // byte count offset of image pixel alignment
|
||||
{[]byte{0x0, 0x01, 0x02, 0x03, 0x04}},
|
||||
{[]byte{0x01, 0x02, 0x03, 0x04, 0x0}},
|
||||
}
|
||||
)
|
||||
|
||||
func TestPNG(t *testing.T) {
|
||||
pngEncoder := new(PNGEncoder)
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
encoded, err := pngEncoder.Encode(test.payload)
|
||||
if err != nil {
|
||||
t.Fatalf("encode: %v", err)
|
||||
}
|
||||
decoded, err := pngEncoder.Decode(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !bytes.Equal(test.payload, decoded) {
|
||||
t.Fatalf("round trip changed the %d-byte payload (decoded %d bytes)", len(test.payload), len(decoded))
|
||||
}
|
||||
})
|
||||
for _, test := range imageTests {
|
||||
buf, _ := pngEncoder.Encode(test.Input)
|
||||
decodeOutput, err := pngEncoder.Decode(buf)
|
||||
if err != nil {
|
||||
t.Errorf("png decode returned error: %q", err)
|
||||
}
|
||||
if !bytes.Equal(test.Input, decodeOutput) {
|
||||
t.Errorf("png Decode(img) => %q, expected %q", decodeOutput, test.Input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPNGRandomDataRandomSize(t *testing.T) {
|
||||
pngEncoder := new(PNGEncoder)
|
||||
for i := 0; i < 100; i++ {
|
||||
sample := randomDataRandomSize(1024 * 1024)
|
||||
buf, _ := pngEncoder.Encode(sample)
|
||||
decodeOutput, err := pngEncoder.Decode(buf)
|
||||
if err != nil {
|
||||
t.Errorf("png decode returned error: %q", err)
|
||||
}
|
||||
if !bytes.Equal(sample, decodeOutput) {
|
||||
t.Errorf("png Decode(img) => %q, expected %q", decodeOutput, sample)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,9 +33,6 @@ linters:
|
||||
generated: lax
|
||||
presets:
|
||||
- common-false-positives
|
||||
settings:
|
||||
exhaustive:
|
||||
default-signifies-exhaustive: true
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
|
||||
+2
-2
@@ -64,7 +64,7 @@ import will be the Bubble Tea library, which we'll call `tea` for short.
|
||||
```go
|
||||
package main
|
||||
|
||||
// These imports will be used later in the tutorial. If you save the file
|
||||
// These imports will be used later on the tutorial. If you save the file
|
||||
// now, Go might complain they are unused, but that's fine.
|
||||
// You may also need to run `go mod tidy` to download bubbletea and its
|
||||
// dependencies.
|
||||
@@ -397,6 +397,6 @@ of days past.
|
||||
|
||||
Part of [Charm](https://charm.sh).
|
||||
|
||||
<a href="https://charm.sh/"><img alt="The Charm logo" src="https://stuff.charm.sh/charm-banner-softy.jpg" width="400"></a>
|
||||
<a href="https://charm.sh/"><img alt="The Charm logo" src="https://stuff.charm.sh/charm-banner-next.jpg" width="400"></a>
|
||||
|
||||
Charm热爱开源 • Charm loves open source • نحنُ نحب المصادر المفتوحة
|
||||
+2
-26
@@ -1,6 +1,6 @@
|
||||
# https://taskfile.dev
|
||||
|
||||
version: "3"
|
||||
version: '3'
|
||||
|
||||
tasks:
|
||||
lint:
|
||||
@@ -11,28 +11,4 @@ tasks:
|
||||
test:
|
||||
desc: Run tests
|
||||
cmds:
|
||||
- go test -race -count 4 -cpu 1,4 ./... {{.CLI_ARGS}}
|
||||
|
||||
release:
|
||||
desc: Create and push a new tag following semver
|
||||
vars:
|
||||
NEXT:
|
||||
sh: svu next --always || go run github.com/caarlos0/svu/v3@latest next --always
|
||||
prompt: "This will release {{.NEXT}}. Continue?"
|
||||
preconditions:
|
||||
- sh: '[ $(git symbolic-ref --short HEAD) = "main" ]'
|
||||
msg: Not on main branch
|
||||
- sh: "[ $(git status --porcelain=2 | wc -l) = 0 ]"
|
||||
msg: "Git is dirty"
|
||||
- sh: 'gh run list --workflow build.yml --commit $(git rev-parse HEAD) --status success --json conclusion -q ".[0].conclusion" | grep -q success'
|
||||
msg: "Test build for this commit failed or not present"
|
||||
cmds:
|
||||
- task: fetch-tags
|
||||
- git commit --allow-empty -m "{{.NEXT}}"
|
||||
- git tag --annotate --sign -m "{{.NEXT}}" {{.NEXT}} {{.CLI_ARGS}}
|
||||
- echo "Pushing {{.NEXT}}..."
|
||||
- git push origin main --follow-tags
|
||||
|
||||
fetch-tags:
|
||||
cmds:
|
||||
- git fetch --tags
|
||||
- go test ./... {{.CLI_ARGS}}
|
||||
@@ -98,7 +98,6 @@ The `tea.View` struct has fields for everything that used to be controlled by op
|
||||
| `ForegroundColor` | Set the terminal foreground color |
|
||||
| `BackgroundColor` | Set the terminal background color |
|
||||
| `ProgressBar` | Show a native terminal progress bar |
|
||||
| `ProgramStatus` | Report whether the program is working, waiting on the user, done, or failed (OSC 7501) |
|
||||
| `KeyboardEnhancements` | Request keyboard enhancement features |
|
||||
| `OnMouse` | Intercept mouse messages based on view content |
|
||||
|
||||
|
||||
+2
-4
@@ -38,8 +38,7 @@ func SetClipboard(s string) Cmd {
|
||||
type readClipboardMsg struct{}
|
||||
|
||||
// ReadClipboard produces a command that reads the system clipboard using OSC52.
|
||||
// Note that OSC52 is not supported in all terminals. Use [WithInput] to enable
|
||||
// input, since the reply cannot be read otherwise.
|
||||
// Note that OSC52 is not supported in all terminals.
|
||||
func ReadClipboard() Msg {
|
||||
return readClipboardMsg{}
|
||||
}
|
||||
@@ -65,8 +64,7 @@ type readPrimaryClipboardMsg struct{}
|
||||
// ReadPrimaryClipboard produces a command that reads the primary clipboard
|
||||
// using OSC52. Primary clipboard selection is a feature present in X11 and
|
||||
// Wayland only.
|
||||
// Note that OSC52 is not supported in all terminals. Use [WithInput] to enable
|
||||
// input, since the reply cannot be read otherwise.
|
||||
// Note that OSC52 is not supported in all terminals.
|
||||
func ReadPrimaryClipboard() Msg {
|
||||
return readPrimaryClipboardMsg{}
|
||||
}
|
||||
+4
-8
@@ -10,7 +10,6 @@ import (
|
||||
type backgroundColorMsg struct{}
|
||||
|
||||
// RequestBackgroundColor is a command that requests the terminal background color.
|
||||
// Use [WithInput] to enable input, since the reply cannot be read otherwise.
|
||||
func RequestBackgroundColor() Msg {
|
||||
return backgroundColorMsg{}
|
||||
}
|
||||
@@ -19,7 +18,6 @@ func RequestBackgroundColor() Msg {
|
||||
type foregroundColorMsg struct{}
|
||||
|
||||
// RequestForegroundColor is a command that requests the terminal foreground color.
|
||||
// Use [WithInput] to enable input, since the reply cannot be read otherwise.
|
||||
func RequestForegroundColor() Msg {
|
||||
return foregroundColorMsg{}
|
||||
}
|
||||
@@ -28,7 +26,6 @@ func RequestForegroundColor() Msg {
|
||||
type cursorColorMsg struct{}
|
||||
|
||||
// RequestCursorColor is a command that requests the terminal cursor color.
|
||||
// Use [WithInput] to enable input, since the reply cannot be read otherwise.
|
||||
func RequestCursorColor() Msg {
|
||||
return cursorColorMsg{}
|
||||
}
|
||||
@@ -52,21 +49,20 @@ func (e ForegroundColorMsg) IsDark() bool {
|
||||
// emitted when the program requests the terminal background color with the
|
||||
// [RequestBackgroundColor] Cmd.
|
||||
//
|
||||
// This is commonly used in [Model.Init] to get the terminal background color
|
||||
// This is commonly used in [Update.Init] to get the terminal background color
|
||||
// for style definitions. For that you'll want to call
|
||||
// [BackgroundColorMsg.IsDark] to determine if the color is dark or light. For
|
||||
// example:
|
||||
//
|
||||
// func (m model) Init() Cmd {
|
||||
// return RequestBackgroundColor
|
||||
// func (m Model) Init() (Model, Cmd) {
|
||||
// return m, RequestBackgroundColor()
|
||||
// }
|
||||
//
|
||||
// func (m model) Update(msg Msg) (Model, Cmd) {
|
||||
// func (m Model) Update(msg Msg) (Model, Cmd) {
|
||||
// switch msg := msg.(type) {
|
||||
// case BackgroundColorMsg:
|
||||
// m.styles = newStyles(msg.IsDark())
|
||||
// }
|
||||
// return m, nil
|
||||
// }
|
||||
type BackgroundColorMsg struct{ color.Color }
|
||||
|
||||
|
||||
+3
-3
@@ -9,9 +9,9 @@ import (
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// func (m model) Init() tea.Cmd {
|
||||
// return tea.Batch(someCommand, someOtherCommand)
|
||||
// }
|
||||
// func (m model) Init() (Model, Cmd) {
|
||||
// return m, tea.Batch(someCommand, someOtherCommand)
|
||||
// }
|
||||
func Batch(cmds ...Cmd) Cmd {
|
||||
return compactCmds[BatchMsg](cmds)
|
||||
}
|
||||
|
||||
+26
-143
@@ -33,8 +33,6 @@ type cursedRenderer struct {
|
||||
mapnl bool
|
||||
syncdUpdates bool // whether to use synchronized output mode for updates
|
||||
starting bool // indicates whether the renderer is starting after being stopped
|
||||
pendingErase bool // an scr.Erase() is pending and hasn't been drained by flush yet
|
||||
noInput bool // whether input is disabled, in which case keyboard enhancement queries are pointless
|
||||
}
|
||||
|
||||
var _ renderer = &cursedRenderer{}
|
||||
@@ -57,40 +55,13 @@ func (s *cursedRenderer) setLogger(logger uv.Logger) {
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// setNoInput disables keyboard enhancement requests. When the program runs
|
||||
// without input, the terminal's response to a keyboard enhancement query
|
||||
// would arrive after the program has exited and leak into the shell.
|
||||
func (s *cursedRenderer) setNoInput(noInput bool) {
|
||||
s.noInput = noInput
|
||||
}
|
||||
|
||||
// resetKeyboardEnhancements writes the sequences that reset keyboard
|
||||
// enhancement protocols when switching between the main and alt screens.
|
||||
// modifyOtherKeys has no stack, so it is reset in place; the Kitty keyboard
|
||||
// stack is popped, but only if we previously pushed an entry (i.e. this is
|
||||
// not the first render). With input disabled the keyboard protocol is never
|
||||
// touched.
|
||||
func (s *cursedRenderer) resetKeyboardEnhancements(buf *bytes.Buffer) {
|
||||
if s.noInput {
|
||||
return
|
||||
}
|
||||
_, _ = buf.WriteString(ansi.ResetModifyOtherKeys)
|
||||
if s.lastView != nil {
|
||||
_, _ = buf.WriteString(ansi.PopKittyKeyboard(1))
|
||||
}
|
||||
}
|
||||
|
||||
// setOptimizations sets the cursor movement optimizations.
|
||||
func (s *cursedRenderer) setOptimizations(hardTabs, backspace, mapnl bool) {
|
||||
s.mu.Lock()
|
||||
s.hardTabs = hardTabs
|
||||
s.backspace = backspace
|
||||
s.mapnl = mapnl
|
||||
if s.hardTabs {
|
||||
s.scr.SetTabStops(s.width)
|
||||
} else {
|
||||
s.scr.SetTabStops(-1)
|
||||
}
|
||||
s.scr.SetTabStops(s.width)
|
||||
s.scr.SetBackspace(s.backspace)
|
||||
s.scr.SetMapNewline(s.mapnl)
|
||||
s.mu.Unlock()
|
||||
@@ -156,19 +127,15 @@ func (s *cursedRenderer) start() {
|
||||
if s.lastView.ProgressBar != nil {
|
||||
setProgressBar(s, s.lastView.ProgressBar)
|
||||
}
|
||||
if s.lastView.ProgramStatus != nil {
|
||||
setProgramStatus(s, nil, s.lastView.ProgramStatus)
|
||||
}
|
||||
if !s.noInput {
|
||||
// Enable modifyOtherKeys and Kitty keyboard protocol.
|
||||
// Both can coexist; terminals ignore what they don't support.
|
||||
_, _ = s.scr.WriteString(ansi.SetModifyOtherKeys2)
|
||||
// Enable modifyOtherKeys and Kitty keyboard protocol.
|
||||
// Both can coexist; terminals ignore what they don't support.
|
||||
_, _ = s.scr.WriteString(ansi.SetModifyOtherKeys2)
|
||||
|
||||
kittyFlags := keyboardEnhancementsFlags(s.lastView.KeyboardEnhancements)
|
||||
// The entry was popped when the renderer was stopped, so push a fresh
|
||||
// one for the screen we're about to restore.
|
||||
_, _ = s.scr.WriteString(ansi.PushKittyKeyboard(kittyFlags))
|
||||
kittyFlags := ansi.KittyDisambiguateEscapeCodes
|
||||
if s.lastView.KeyboardEnhancements.ReportEventTypes {
|
||||
kittyFlags |= ansi.KittyReportEventTypes
|
||||
}
|
||||
_, _ = s.scr.WriteString(ansi.KittyKeyboard(kittyFlags, 1))
|
||||
}
|
||||
|
||||
// close implements renderer.
|
||||
@@ -185,14 +152,10 @@ func (s *cursedRenderer) close() (err error) {
|
||||
// two registries for the main and alt screens. We disable keyboard
|
||||
// enhancements whenever we enter/exit alt screen mode in
|
||||
// [cursedRenderer.flush].
|
||||
// Here, we pop the keyboard protocol of the last screen used
|
||||
// assuming the other screen is already popped when we switched
|
||||
// screens. With input disabled we never pushed an entry, so there is
|
||||
// nothing to pop.
|
||||
if !s.noInput {
|
||||
_, _ = s.buf.WriteString(ansi.ResetModifyOtherKeys)
|
||||
_, _ = s.buf.WriteString(ansi.PopKittyKeyboard(1))
|
||||
}
|
||||
// Here, we reset the keyboard protocol of the last screen used
|
||||
// assuming the other screen is already reset when we switched screens.
|
||||
_, _ = s.buf.WriteString(ansi.ResetModifyOtherKeys)
|
||||
_, _ = s.buf.WriteString(ansi.KittyKeyboard(0, 1))
|
||||
|
||||
// Go to the bottom of the screen.
|
||||
// We need to go to the bottom of the screen regardless of whether
|
||||
@@ -248,9 +211,6 @@ func (s *cursedRenderer) close() (err error) {
|
||||
if lv.ProgressBar != nil && lv.ProgressBar.State != ProgressBarNone {
|
||||
_, _ = s.scr.WriteString(ansi.ResetProgressBar)
|
||||
}
|
||||
// Unlike the progress bar, the program status is left in place: done
|
||||
// and error reports should outlive the program, and terminals drop
|
||||
// working and blocked records when the process exits.
|
||||
}
|
||||
|
||||
if s.cellbuf.Method == ansi.GraphemeWidth {
|
||||
@@ -318,19 +278,13 @@ func (s *cursedRenderer) flush(closing bool) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Restore tab stops if we have tab optimizations enabled.
|
||||
if s.starting && s.hardTabs {
|
||||
_, _ = s.scr.WriteString(ansi.SetTabEvery8Columns)
|
||||
}
|
||||
|
||||
if !s.starting && !closing && !s.pendingErase && s.lastView != nil && viewEquals(s.lastView, &view) && frameArea == s.cellbuf.Bounds() {
|
||||
if !s.starting && !closing && s.lastView != nil && viewEquals(s.lastView, &view) && frameArea == s.cellbuf.Bounds() {
|
||||
// No changes, nothing to do.
|
||||
return nil
|
||||
}
|
||||
|
||||
// We're no longer starting.
|
||||
s.starting = false
|
||||
s.pendingErase = false
|
||||
|
||||
if frameArea != s.cellbuf.Bounds() {
|
||||
s.scr.Erase() // Force a full redraw to avoid artifacts.
|
||||
@@ -415,12 +369,9 @@ func (s *cursedRenderer) flush(closing bool) error {
|
||||
}
|
||||
}
|
||||
|
||||
// kitty keyboard protocol. Skipped entirely when input is disabled: the
|
||||
// enhancements only affect keyboard input, and querying the terminal
|
||||
// would leave its response unconsumed, leaking into the shell after
|
||||
// the program exits.
|
||||
if !s.noInput && (s.lastView == nil || view.KeyboardEnhancements != s.lastView.KeyboardEnhancements ||
|
||||
view.AltScreen != s.lastView.AltScreen) {
|
||||
// kitty keyboard protocol
|
||||
if s.lastView == nil || view.KeyboardEnhancements != s.lastView.KeyboardEnhancements ||
|
||||
view.AltScreen != s.lastView.AltScreen {
|
||||
// NOTE: We need to reset the keyboard protocol when switching
|
||||
// between main and alt screen. This is because the specs specify
|
||||
// two different states for the main and alt screen.
|
||||
@@ -428,20 +379,11 @@ func (s *cursedRenderer) flush(closing bool) error {
|
||||
// Enable modifyOtherKeys and Kitty keyboard protocol.
|
||||
_, _ = s.scr.WriteString(ansi.SetModifyOtherKeys2)
|
||||
|
||||
kittyFlags := keyboardEnhancementsFlags(view.KeyboardEnhancements)
|
||||
if s.lastView == nil || view.AltScreen != s.lastView.AltScreen {
|
||||
// First render or screen switch: the previous screen's entry
|
||||
// (if any) is popped below, so push a fresh one for this
|
||||
// screen.
|
||||
_, _ = s.scr.WriteString(ansi.PushKittyKeyboard(kittyFlags))
|
||||
} else {
|
||||
// Only the flags changed while the same screen stays active.
|
||||
// Update the topmost stack entry in place instead of popping
|
||||
// and re-pushing, so a keyboard change doesn't churn the
|
||||
// stack. Note that this overwrites whatever entry is currently
|
||||
// on top, which is normally ours.
|
||||
_, _ = s.scr.WriteString(ansi.KittyKeyboard(kittyFlags, 1))
|
||||
kittyFlags := ansi.KittyDisambiguateEscapeCodes // always enable basic key disambiguation
|
||||
if view.KeyboardEnhancements.ReportEventTypes {
|
||||
kittyFlags |= ansi.KittyReportEventTypes
|
||||
}
|
||||
_, _ = s.scr.WriteString(ansi.KittyKeyboard(kittyFlags, 1))
|
||||
if !closing {
|
||||
// Request keyboard enhancements when they change
|
||||
_, _ = s.scr.WriteString(ansi.RequestKittyKeyboard)
|
||||
@@ -512,15 +454,6 @@ func (s *cursedRenderer) flush(closing bool) error {
|
||||
setProgressBar(s, view.ProgressBar)
|
||||
}
|
||||
|
||||
// Report the program status if it's changed.
|
||||
var lps *ProgramStatus
|
||||
if s.lastView != nil {
|
||||
lps = s.lastView.ProgramStatus
|
||||
}
|
||||
if !programStatusEquals(lps, view.ProgramStatus) {
|
||||
setProgramStatus(s, lps, view.ProgramStatus)
|
||||
}
|
||||
|
||||
// Render and queue changes to the screen buffer.
|
||||
s.scr.Render(s.cellbuf.RenderBuffer)
|
||||
|
||||
@@ -575,10 +508,11 @@ func (s *cursedRenderer) flush(closing bool) error {
|
||||
|
||||
var buf bytes.Buffer
|
||||
if shouldUpdateAltScreen {
|
||||
// We always reset keyboard enhancements when switching screens
|
||||
// We always disable keyboard enhancements when switching screens
|
||||
// because the terminal is expected to have two different keyboard
|
||||
// registries for main and alt screens.
|
||||
s.resetKeyboardEnhancements(&buf)
|
||||
_, _ = buf.WriteString(ansi.ResetModifyOtherKeys)
|
||||
_, _ = buf.WriteString(ansi.KittyKeyboard(0, 1))
|
||||
if view.AltScreen {
|
||||
// Entering alt screen mode.
|
||||
buf.WriteString(ansi.SetModeAltScreenSaveCursor)
|
||||
@@ -659,11 +593,7 @@ func reset(s *cursedRenderer) {
|
||||
scr.SetColorProfile(s.profile)
|
||||
scr.SetRelativeCursor(true) // Always start in inline mode
|
||||
scr.SetFullscreen(false) // Always start in inline mode
|
||||
if s.hardTabs {
|
||||
scr.SetTabStops(s.width)
|
||||
} else {
|
||||
scr.SetTabStops(-1)
|
||||
}
|
||||
scr.SetTabStops(s.width)
|
||||
scr.SetBackspace(s.backspace)
|
||||
scr.SetMapNewline(s.mapnl)
|
||||
scr.SetScrollOptim(runtime.GOOS != "windows") // disable scroll optimization on Windows due to bugs in some terminals
|
||||
@@ -690,7 +620,6 @@ func (s *cursedRenderer) resize(w, h int) {
|
||||
s.scr.Erase()
|
||||
s.width, s.height = w, h
|
||||
s.scr.Resize(s.width, s.height)
|
||||
s.pendingErase = true
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -701,7 +630,6 @@ func (s *cursedRenderer) clearScreen() {
|
||||
// screen redraw.
|
||||
s.scr.MoveTo(0, 0)
|
||||
s.scr.Erase()
|
||||
s.pendingErase = true
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -829,14 +757,8 @@ func (s *cursedRenderer) insertAbove(str string) error {
|
||||
|
||||
// onMouse implements renderer.
|
||||
func (s *cursedRenderer) onMouse(m MouseMsg) Cmd {
|
||||
var onMouse func(MouseMsg) Cmd
|
||||
s.mu.Lock()
|
||||
if s.lastView != nil {
|
||||
onMouse = s.lastView.OnMouse
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if onMouse != nil {
|
||||
return onMouse(m)
|
||||
if s.lastView != nil && s.lastView.OnMouse != nil {
|
||||
return s.lastView.OnMouse(m)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -865,33 +787,11 @@ func setProgressBar(s *cursedRenderer, pb *ProgressBar) {
|
||||
}
|
||||
}
|
||||
|
||||
func programStatusEquals(a, b *ProgramStatus) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == b
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
||||
// setProgramStatus reports cur to the terminal. When cur is nil, or addresses
|
||||
// a different record than prev, the record of prev is cleared first.
|
||||
func setProgramStatus(s *cursedRenderer, prev, cur *ProgramStatus) {
|
||||
if prev != nil && (cur == nil || cur.ID != prev.ID) {
|
||||
_, _ = s.scr.WriteString(ansi.ClearProgramStatusID(prev.ID))
|
||||
}
|
||||
if cur != nil {
|
||||
_, _ = s.scr.WriteString(ansi.SetProgramStatus(*cur))
|
||||
}
|
||||
}
|
||||
|
||||
func viewEquals(a, b *View) bool {
|
||||
if a == nil || b == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if !programStatusEquals(a.ProgramStatus, b.ProgramStatus) {
|
||||
return false
|
||||
}
|
||||
|
||||
if a.Content != b.Content ||
|
||||
a.AltScreen != b.AltScreen ||
|
||||
a.DisableBracketedPasteMode != b.DisableBracketedPasteMode ||
|
||||
@@ -928,20 +828,3 @@ func viewEquals(a, b *View) bool {
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func keyboardEnhancementsFlags(ke KeyboardEnhancements) int {
|
||||
flags := 1 // always enable basic key disambiguation
|
||||
if ke.ReportEventTypes {
|
||||
flags |= ansi.KittyReportEventTypes
|
||||
}
|
||||
if ke.ReportAlternateKeys {
|
||||
flags |= ansi.KittyReportAlternateKeys
|
||||
}
|
||||
if ke.ReportAllKeysAsEscapeCodes {
|
||||
flags |= ansi.KittyReportAllKeysAsEscapeCodes
|
||||
}
|
||||
if ke.ReportAssociatedText {
|
||||
flags |= ansi.KittyReportAssociatedKeys
|
||||
}
|
||||
return flags
|
||||
}
|
||||
-1
@@ -23,7 +23,6 @@ type requestCursorPosMsg struct{}
|
||||
|
||||
// RequestCursorPosition is a command that requests the cursor position.
|
||||
// The cursor position will be sent as a [CursorPositionMsg] message.
|
||||
// Use [WithInput] to enable input, since the reply cannot be read otherwise.
|
||||
func RequestCursorPosition() Msg {
|
||||
return requestCursorPosMsg{}
|
||||
}
|
||||
-2
@@ -49,8 +49,6 @@ func (p *Program) translateInputEvent(e uv.Event) Msg {
|
||||
return KeyboardEnhancementsMsg(e)
|
||||
case uv.ModeReportEvent:
|
||||
return ModeReportMsg(e)
|
||||
case uv.ProgramStatusSupportEvent:
|
||||
return ProgramStatusSupportMsg{}
|
||||
}
|
||||
return e
|
||||
}
|
||||
+1
-1
@@ -152,7 +152,7 @@ const (
|
||||
KeyMediaRewind = uv.KeyMediaRewind
|
||||
KeyMediaNext = uv.KeyMediaNext
|
||||
KeyMediaPrev = uv.KeyMediaPrev
|
||||
KeyMediaRecord = uv.KeyMediaRecord
|
||||
KeyMediaRecord
|
||||
|
||||
KeyLowerVol = uv.KeyLowerVol
|
||||
KeyRaiseVol = uv.KeyRaiseVol
|
||||
|
||||
-18
@@ -39,21 +39,3 @@ func (k KeyboardEnhancementsMsg) SupportsKeyDisambiguation() bool {
|
||||
func (k KeyboardEnhancementsMsg) SupportsEventTypes() bool {
|
||||
return k.Flags&ansi.KittyReportEventTypes != 0
|
||||
}
|
||||
|
||||
// SupportsAlternateKeys returns whether the terminal supports reporting
|
||||
// alternate key codes.
|
||||
func (k KeyboardEnhancementsMsg) SupportsAlternateKeys() bool {
|
||||
return k.Flags&ansi.KittyReportAlternateKeys != 0
|
||||
}
|
||||
|
||||
// SupportsAllKeysAsEscapeCodes returns whether the terminal supports reporting
|
||||
// all keys as escape codes.
|
||||
func (k KeyboardEnhancementsMsg) SupportsAllKeysAsEscapeCodes() bool {
|
||||
return k.Flags&ansi.KittyReportAllKeysAsEscapeCodes != 0
|
||||
}
|
||||
|
||||
// SupportsAssociatedText returns whether the terminal supports reporting
|
||||
// associated text with key events.
|
||||
func (k KeyboardEnhancementsMsg) SupportsAssociatedText() bool {
|
||||
return k.Flags&ansi.KittyReportAssociatedKeys != 0
|
||||
}
|
||||
+1
-1
@@ -38,7 +38,7 @@ const (
|
||||
MouseBackward = uv.MouseBackward
|
||||
MouseForward = uv.MouseForward
|
||||
MouseButton10 = uv.MouseButton10
|
||||
MouseButton11 = uv.MouseButton11
|
||||
MouseButton11
|
||||
)
|
||||
|
||||
// MouseMsg represents a mouse message. This is a generic mouse message that
|
||||
|
||||
+1
-8
@@ -37,13 +37,6 @@ func WithOutput(output io.Writer) ProgramOption {
|
||||
// won't need to use this. To disable input entirely pass nil.
|
||||
//
|
||||
// p := NewProgram(model, WithInput(nil))
|
||||
//
|
||||
// Note that with input disabled, no terminal query is ever sent, since the
|
||||
// reply could not be read: commands that expect a response, such as
|
||||
// [RequestBackgroundColor], [RequestCursorPosition], [ReadClipboard], or
|
||||
// [RequestTerminalVersion], are skipped, as is the startup capability probe.
|
||||
// A model waiting on the reply message of any of those commands will never
|
||||
// receive it.
|
||||
func WithInput(input io.Reader) ProgramOption {
|
||||
return func(p *Program) {
|
||||
p.input = input
|
||||
@@ -61,7 +54,7 @@ func WithInput(input io.Reader) ProgramOption {
|
||||
// var sess ssh.Session // ssh.Session is a type from the github.com/charmbracelet/ssh package
|
||||
// pty, _, _ := sess.Pty()
|
||||
// environ := append(sess.Environ(), "TERM="+pty.Term)
|
||||
// p := tea.NewProgram(model, tea.WithEnvironment(environ))
|
||||
// p := tea.NewProgram(model, tea.WithEnvironment(environ)
|
||||
func WithEnvironment(env []string) ProgramOption {
|
||||
return func(p *Program) {
|
||||
p.environ = env
|
||||
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
package tea
|
||||
|
||||
import uv "github.com/charmbracelet/ultraviolet"
|
||||
|
||||
// ProgramStatus is a Program Status Protocol (OSC 7501) report. It tells the
|
||||
// terminal whether the program is idle, working, waiting on the user, done,
|
||||
// or failed, and why. Set it on [View.ProgramStatus].
|
||||
//
|
||||
// Support depends on the terminal. Terminals that don't support it ignore it.
|
||||
//
|
||||
// See: https://www.superlogical.com/rex/docs/build/program-status
|
||||
type ProgramStatus = uv.ProgramStatus
|
||||
|
||||
// ProgramState is the state of a [ProgramStatus].
|
||||
type ProgramState = uv.ProgramState
|
||||
|
||||
// ProgramStatusKind says what a blocked program waits for.
|
||||
type ProgramStatusKind = uv.ProgramStatusKind
|
||||
|
||||
// Program states.
|
||||
const (
|
||||
ProgramStateIdle = uv.ProgramStateIdle
|
||||
ProgramStateWorking = uv.ProgramStateWorking
|
||||
ProgramStateDone = uv.ProgramStateDone
|
||||
ProgramStateBlocked = uv.ProgramStateBlocked
|
||||
ProgramStateError = uv.ProgramStateError
|
||||
ProgramStateClear = uv.ProgramStateClear
|
||||
)
|
||||
|
||||
// Program status kinds.
|
||||
const (
|
||||
ProgramStatusKindPermission = uv.ProgramStatusKindPermission
|
||||
ProgramStatusKindQuestion = uv.ProgramStatusKindQuestion
|
||||
ProgramStatusKindAuth = uv.ProgramStatusKindAuth
|
||||
)
|
||||
|
||||
// ProgramStatusSupportMsg is sent when the terminal replies to
|
||||
// [RequestProgramStatusSupport], meaning it supports the Program Status
|
||||
// Protocol.
|
||||
type ProgramStatusSupportMsg struct{}
|
||||
|
||||
// programStatusSupportMsg is an internal message that queries the terminal
|
||||
// for Program Status Protocol support.
|
||||
type programStatusSupportMsg struct{}
|
||||
|
||||
// RequestProgramStatusSupport is a command that asks the terminal whether it
|
||||
// supports the Program Status Protocol (OSC 7501). A supporting terminal
|
||||
// replies with a [ProgramStatusSupportMsg]. Terminals that don't support it
|
||||
// never reply, so pick your own timeout. Use [WithInput] to enable input,
|
||||
// since the reply cannot be read otherwise.
|
||||
//
|
||||
// Detection is optional: reports can be sent without it.
|
||||
func RequestProgramStatusSupport() Msg {
|
||||
return programStatusSupportMsg{}
|
||||
}
|
||||
+3
-1
@@ -3,7 +3,9 @@ package tea
|
||||
import "github.com/charmbracelet/x/ansi"
|
||||
|
||||
// WindowSizeMsg is used to report the terminal size. It's sent to Update once
|
||||
// initially and then on every terminal resize.
|
||||
// initially and then on every terminal resize. Note that Windows does not
|
||||
// have support for reporting when resizes occur as it does not support the
|
||||
// SIGWINCH signal.
|
||||
type WindowSizeMsg struct {
|
||||
Width int
|
||||
Height int
|
||||
|
||||
+28
-93
@@ -4,9 +4,9 @@
|
||||
// both. It's been battle-tested in several large projects and is
|
||||
// production-ready.
|
||||
//
|
||||
// A tutorial is available at https://github.com/charmbracelet/bubbletea/tree/main/tutorials
|
||||
// A tutorial is available at https://github.com/charmbracelet/bubbletea/tree/master/tutorials
|
||||
//
|
||||
// Example programs can be found at https://github.com/charmbracelet/bubbletea/tree/main/examples
|
||||
// Example programs can be found at https://github.com/charmbracelet/bubbletea/tree/master/examples
|
||||
package tea
|
||||
|
||||
import (
|
||||
@@ -146,20 +146,6 @@ type View struct {
|
||||
// progress bar section. Support depends on the terminal.
|
||||
ProgressBar *ProgressBar
|
||||
|
||||
// ProgramStatus when not nil, reports the program status to the terminal
|
||||
// using the Program Status Protocol (OSC 7501): whether the program is
|
||||
// idle, working, waiting on the user, done, or failed. Support depends on
|
||||
// the terminal.
|
||||
//
|
||||
// The status usually addresses the root record (empty ID). Changing it
|
||||
// sends a new report, and setting it back to nil clears the record.
|
||||
//
|
||||
// The status is not cleared when the program exits, so done and error
|
||||
// reports stay visible to the user. Report [ProgramStateDone],
|
||||
// [ProgramStateError], or [ProgramStateIdle] before quitting rather than
|
||||
// leaving a working or blocked status behind.
|
||||
ProgramStatus *ProgramStatus
|
||||
|
||||
// AltScreen puts the program in the alternate screen buffer
|
||||
// (i.e. the program goes into full window mode). Note that the altscreen will
|
||||
// be automatically exited when the program quits.
|
||||
@@ -259,25 +245,6 @@ type KeyboardEnhancements struct {
|
||||
// [KeyPressMsg] with the [Key.IsRepeat] field set indicating that this is
|
||||
// a it's part of a key repeat sequence.
|
||||
ReportEventTypes bool
|
||||
|
||||
// ReportAlternateKeys requests the terminal to report alternate key values
|
||||
// in addition to the main ones.
|
||||
// Note that only key events represented as escape codes will affected by
|
||||
// this enhancement.
|
||||
ReportAlternateKeys bool
|
||||
|
||||
// ReportAllKeysAsEscapeCodes requests the terminal to report all key
|
||||
// events, including plain text keys, as escape codes.
|
||||
// When this is enabled, text won't be sent as plain text but instead as
|
||||
// escape codes that encode the key value and modifiers.
|
||||
ReportAllKeysAsEscapeCodes bool
|
||||
|
||||
// ReportAssociatedText requests the terminal to report the text associated
|
||||
// with key events.
|
||||
// Note that this is an enhancement to
|
||||
// [KeyboardEnhancements.ReportAllKeysAsEscapeCodes] and only has an effect
|
||||
// if that is enabled.
|
||||
ReportAssociatedText bool
|
||||
}
|
||||
|
||||
// SetContent is a helper method to set the content of a [View] with a styled
|
||||
@@ -331,23 +298,15 @@ const (
|
||||
ProgressBarWarning
|
||||
)
|
||||
|
||||
// String returns a human-readable name for the given [ProgressBarState].
|
||||
// Values outside the known range return "Unknown".
|
||||
// String return a human-readable value for the given [ProgressBarState].
|
||||
func (s ProgressBarState) String() string {
|
||||
switch s {
|
||||
case ProgressBarNone:
|
||||
return "None"
|
||||
case ProgressBarDefault:
|
||||
return "Default"
|
||||
case ProgressBarError:
|
||||
return "Error"
|
||||
case ProgressBarIndeterminate:
|
||||
return "Indeterminate"
|
||||
case ProgressBarWarning:
|
||||
return "Warning"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
return [...]string{
|
||||
"None",
|
||||
"Default",
|
||||
"Error",
|
||||
"Indeterminate",
|
||||
"Warning",
|
||||
}[s]
|
||||
}
|
||||
|
||||
// ProgressBar represents the terminal progress bar.
|
||||
@@ -358,7 +317,7 @@ func (s ProgressBarState) String() string {
|
||||
type ProgressBar struct {
|
||||
// State is the current state of the progress bar. It can be one of
|
||||
// [ProgressBarNone], [ProgressBarDefault], [ProgressBarError],
|
||||
// [ProgressBarIndeterminate], and [ProgressBarWarning].
|
||||
// [ProgressBarIndeterminate], and [ProgressBarWarn].
|
||||
State ProgressBarState
|
||||
// Value is the current value of the progress bar. It should be between
|
||||
// 0 and 100.
|
||||
@@ -688,13 +647,11 @@ func (p *Program) handleSignals() chan struct{} {
|
||||
|
||||
case s := <-sig:
|
||||
if atomic.LoadUint32(&p.ignoreSignals) == 0 {
|
||||
var msg Msg = QuitMsg{}
|
||||
if s == syscall.SIGINT {
|
||||
msg = InterruptMsg{}
|
||||
}
|
||||
select {
|
||||
case p.msgs <- msg:
|
||||
case <-p.ctx.Done():
|
||||
switch s {
|
||||
case syscall.SIGINT:
|
||||
p.msgs <- InterruptMsg{}
|
||||
default:
|
||||
p.msgs <- QuitMsg{}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -832,38 +789,35 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
|
||||
}
|
||||
|
||||
case readClipboardMsg:
|
||||
p.executeQuery(ansi.RequestSystemClipboard)
|
||||
p.execute(ansi.RequestSystemClipboard)
|
||||
|
||||
case setClipboardMsg:
|
||||
p.execute(ansi.SetSystemClipboard(string(msg)))
|
||||
|
||||
case readPrimaryClipboardMsg:
|
||||
p.executeQuery(ansi.RequestPrimaryClipboard)
|
||||
p.execute(ansi.RequestPrimaryClipboard)
|
||||
|
||||
case setPrimaryClipboardMsg:
|
||||
p.execute(ansi.SetPrimaryClipboard(string(msg)))
|
||||
|
||||
case backgroundColorMsg:
|
||||
p.executeQuery(ansi.RequestBackgroundColor)
|
||||
p.execute(ansi.RequestBackgroundColor)
|
||||
|
||||
case foregroundColorMsg:
|
||||
p.executeQuery(ansi.RequestForegroundColor)
|
||||
p.execute(ansi.RequestForegroundColor)
|
||||
|
||||
case cursorColorMsg:
|
||||
p.executeQuery(ansi.RequestCursorColor)
|
||||
|
||||
case programStatusSupportMsg:
|
||||
p.executeQuery(ansi.RequestProgramStatusSupport)
|
||||
p.execute(ansi.RequestCursorColor)
|
||||
|
||||
case execMsg:
|
||||
// NB: this blocks.
|
||||
p.exec(msg.cmd, msg.fn)
|
||||
|
||||
case terminalVersion:
|
||||
p.executeQuery(ansi.RequestNameVersion)
|
||||
p.execute(ansi.RequestNameVersion)
|
||||
|
||||
case requestCapabilityMsg:
|
||||
p.executeQuery(ansi.RequestTermcap(string(msg)))
|
||||
p.execute(ansi.RequestTermcap(string(msg)))
|
||||
|
||||
case BatchMsg:
|
||||
go p.execBatchMsg(msg)
|
||||
@@ -880,7 +834,7 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) {
|
||||
go p.checkResize()
|
||||
|
||||
case requestCursorPosMsg:
|
||||
p.executeQuery(ansi.RequestCursorPositionReport)
|
||||
p.execute(ansi.RequestCursorPositionReport)
|
||||
|
||||
case RawMsg:
|
||||
p.execute(fmt.Sprint(msg.Msg))
|
||||
@@ -1074,7 +1028,7 @@ func (p *Program) Run() (returnModel Model, returnErr error) {
|
||||
return p.initialModel, fmt.Errorf("bubbletea: error getting terminal size: %w", err)
|
||||
}
|
||||
|
||||
width, height = p.fallbackDimensions(w, h)
|
||||
width, height = w, h
|
||||
}
|
||||
|
||||
p.width, p.height = width, height
|
||||
@@ -1092,7 +1046,6 @@ func (p *Program) Run() (returnModel Model, returnErr error) {
|
||||
p.height,
|
||||
)
|
||||
r.setLogger(p.logger)
|
||||
r.setNoInput(p.disableInput)
|
||||
// XXX: This breaks many things especially when we want the output
|
||||
// to be compatible with terminals that are not necessary a TTY.
|
||||
// This was originally done to work around a Wish emulated-pty
|
||||
@@ -1138,7 +1091,7 @@ func (p *Program) Run() (returnModel Model, returnErr error) {
|
||||
// Query for synchronized updates support (mode 2026) and unicode core
|
||||
// (mode 2027). If the terminal supports it, the renderer will enable
|
||||
// it once we get the response.
|
||||
p.executeQuery(ansi.RequestModeSynchronizedOutput +
|
||||
p.execute(ansi.RequestModeSynchronizedOutput +
|
||||
ansi.RequestModeUnicodeCore)
|
||||
}
|
||||
|
||||
@@ -1245,22 +1198,6 @@ func (p *Program) execute(seq string) {
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
// executeQuery writes a terminal query to the program output. Queries expect a
|
||||
// reply on p.input, so when input is disabled the query is skipped: the
|
||||
// program cannot read the response, and the terminal's reply would leak into
|
||||
// the shell once the program exits (#1590).
|
||||
//
|
||||
// This covers every query that expects a reply, not just the startup
|
||||
// capability probe: background, foreground, and cursor color requests, cursor
|
||||
// position reports, clipboard reads, and terminal version and termcap
|
||||
// queries.
|
||||
func (p *Program) executeQuery(seq string) {
|
||||
if p.disableInput {
|
||||
return
|
||||
}
|
||||
p.execute(seq)
|
||||
}
|
||||
|
||||
// flush flushes the output buffer to the program output.
|
||||
func (p *Program) flush() error {
|
||||
p.mu.Lock()
|
||||
@@ -1391,10 +1328,8 @@ func (p *Program) RestoreTerminal() error {
|
||||
if err := p.initTerminal(); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.input != nil {
|
||||
if err := p.initInputReader(false); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.initInputReader(false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p.startRenderer()
|
||||
|
||||
+1
-2
@@ -5,8 +5,7 @@ package tea
|
||||
type requestCapabilityMsg string
|
||||
|
||||
// RequestCapability is a command that requests the terminal to send its
|
||||
// Termcap/Terminfo response for the given capability. Use [WithInput] to
|
||||
// enable input, since the reply cannot be read otherwise.
|
||||
// Termcap/Terminfo response for the given capability.
|
||||
//
|
||||
// Bubble Tea recognizes the following capabilities and will use them to
|
||||
// upgrade the program's color profile:
|
||||
|
||||
-40
@@ -3,7 +3,6 @@ package tea
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
uv "github.com/charmbracelet/ultraviolet"
|
||||
@@ -105,43 +104,6 @@ func (p *Program) waitForReadLoop() {
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
defaultWidth = 80
|
||||
defaultHeight = 24
|
||||
)
|
||||
|
||||
// fallbackDimensions returns width and height, falling back to COLUMNS/LINES
|
||||
// environment variables or 80x24 defaults when dimensions are non-positive.
|
||||
func (p *Program) fallbackDimensions(width, height int) (int, int) {
|
||||
if width <= 0 {
|
||||
var cols string
|
||||
if p != nil && p.environ != nil {
|
||||
cols = p.environ.Getenv("COLUMNS")
|
||||
} else {
|
||||
cols = os.Getenv("COLUMNS")
|
||||
}
|
||||
if c, err := strconv.Atoi(cols); err == nil && c > 0 {
|
||||
width = c
|
||||
} else {
|
||||
width = defaultWidth
|
||||
}
|
||||
}
|
||||
if height <= 0 {
|
||||
var lines string
|
||||
if p != nil && p.environ != nil {
|
||||
lines = p.environ.Getenv("LINES")
|
||||
} else {
|
||||
lines = os.Getenv("LINES")
|
||||
}
|
||||
if l, err := strconv.Atoi(lines); err == nil && l > 0 {
|
||||
height = l
|
||||
} else {
|
||||
height = defaultHeight
|
||||
}
|
||||
}
|
||||
return width, height
|
||||
}
|
||||
|
||||
// checkResize detects the current size of the output and informs the program
|
||||
// via a WindowSizeMsg.
|
||||
func (p *Program) checkResize() {
|
||||
@@ -160,8 +122,6 @@ func (p *Program) checkResize() {
|
||||
return
|
||||
}
|
||||
|
||||
w, h = p.fallbackDimensions(w, h)
|
||||
|
||||
p.width, p.height = w, h
|
||||
p.Send(WindowSizeMsg{Width: w, Height: h})
|
||||
}
|
||||
|
||||
-1
@@ -40,7 +40,6 @@ const suspendSupported = true
|
||||
func suspendProcess() {
|
||||
c := make(chan os.Signal, 1)
|
||||
signal.Notify(c, syscall.SIGCONT)
|
||||
defer signal.Stop(c)
|
||||
_ = syscall.Kill(0, syscall.SIGTSTP)
|
||||
// blocks until a CONT happens...
|
||||
<-c
|
||||
|
||||
+1
-2
@@ -16,8 +16,7 @@ type terminalVersion struct{}
|
||||
|
||||
// RequestTerminalVersion is a command that queries the terminal for its
|
||||
// version using XTVERSION. Note that some terminals may not support this
|
||||
// command. Use [WithInput] to enable input, since the reply cannot be read
|
||||
// otherwise.
|
||||
// command.
|
||||
func RequestTerminalVersion() Msg {
|
||||
return terminalVersion{}
|
||||
}
|
||||
+11
-39
@@ -648,11 +648,6 @@ func TrimSpace(s string) string {
|
||||
// parts of the screen that have changed.
|
||||
type RenderBuffer struct {
|
||||
*Buffer
|
||||
|
||||
// Touched records which lines changed since the last render. It holds at
|
||||
// least one entry per screen row, and a nil entry means that row is
|
||||
// unchanged. Shortening it is not meaningful, since the rows still exist, so
|
||||
// the renderer restores the length rather than checking it at every read.
|
||||
Touched []*LineData
|
||||
}
|
||||
|
||||
@@ -670,7 +665,14 @@ func (b *RenderBuffer) TouchLine(x, y, n int) {
|
||||
return
|
||||
}
|
||||
|
||||
b.growTouched()
|
||||
if y >= len(b.Touched) {
|
||||
b.Touched = append(b.Touched, make([]*LineData, y-len(b.Touched)+1)...)
|
||||
}
|
||||
|
||||
// Re-check bounds: a concurrent resize may have cleared Touched
|
||||
if y >= len(b.Touched) {
|
||||
return
|
||||
}
|
||||
|
||||
ch := b.Touched[y]
|
||||
if ch == nil {
|
||||
@@ -689,6 +691,9 @@ func (b *RenderBuffer) Touch(x, y int) {
|
||||
|
||||
// TouchedLines returns the number of touched lines in the buffer.
|
||||
func (b *RenderBuffer) TouchedLines() int {
|
||||
if b.Touched == nil {
|
||||
return 0
|
||||
}
|
||||
count := 0
|
||||
for _, t := range b.Touched {
|
||||
if t != nil {
|
||||
@@ -794,22 +799,6 @@ func (b *RenderBuffer) DeleteCellArea(x, y, n int, c *Cell, area Rectangle) {
|
||||
b.TouchLine(x, y, n)
|
||||
}
|
||||
|
||||
// Resize resizes the buffer and touches every cell it creates, since the
|
||||
// screen there still shows whatever the last render left.
|
||||
func (b *RenderBuffer) Resize(width, height int) {
|
||||
curWidth, curHeight := b.Width(), b.Height()
|
||||
b.Buffer.Resize(width, height)
|
||||
|
||||
if width > curWidth {
|
||||
for y := range min(curHeight, height) {
|
||||
b.TouchLine(curWidth, y, width-curWidth)
|
||||
}
|
||||
}
|
||||
for y := curHeight; y < height; y++ {
|
||||
b.TouchLine(0, y, width)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear clears the buffer with space cells and marks all lines as touched.
|
||||
func (b *RenderBuffer) Clear() {
|
||||
b.Buffer.Clear()
|
||||
@@ -843,20 +832,3 @@ func (b *RenderBuffer) FillArea(c *Cell, area Rectangle) {
|
||||
b.TouchLine(area.Min.X, y, w)
|
||||
}
|
||||
}
|
||||
|
||||
// growTouched restores the one-entry-per-row invariant, which an application is
|
||||
// free to break by shortening or dropping the list.
|
||||
//
|
||||
// Only ever grows. Truncating to the screen would be the tidier rule, but
|
||||
// [RenderBuffer.TouchedLines] counts non-nil records rather than touched ones,
|
||||
// so discarding the entries of a frame that collapsed makes it look untouched
|
||||
// and skips the erase that clears the rows it gave up. Growing is enough: every
|
||||
// reader indexes by screen row, and a longer list has an entry for each.
|
||||
func (b *RenderBuffer) growTouched() {
|
||||
if len(b.Touched) >= len(b.Lines) {
|
||||
return
|
||||
}
|
||||
touched := make([]*LineData, len(b.Lines))
|
||||
copy(touched, b.Touched)
|
||||
b.Touched = touched
|
||||
}
|
||||
+1
-5
@@ -854,10 +854,6 @@ func (p *EventDecoder) parseOsc(b []byte) (int, Event) {
|
||||
|
||||
sel := ClipboardSelection(parts[0][0]) //nolint:unconvert
|
||||
return i, ClipboardEvent{Selection: sel, Content: string(bts)}
|
||||
case 7501:
|
||||
if data[0] == '?' {
|
||||
return i, ProgramStatusSupportEvent{}
|
||||
}
|
||||
}
|
||||
|
||||
return i, UnknownOscEvent(b[:i])
|
||||
@@ -1765,7 +1761,7 @@ func rgbToHSL(r, g, b uint8) (h, s, l float64) {
|
||||
} else {
|
||||
switch Cmax {
|
||||
case Rnot:
|
||||
h = 60 * math.Mod((Gnot-Bnot)/Δ, 6)
|
||||
h = 60 * (math.Mod((Gnot-Bnot)/Δ, 6))
|
||||
case Gnot:
|
||||
h = 60 * (((Bnot - Rnot) / Δ) + 2)
|
||||
case Bnot:
|
||||
|
||||
-7
@@ -562,13 +562,6 @@ func (e ClipboardEvent) Clipboard() ClipboardSelection {
|
||||
return e.Selection
|
||||
}
|
||||
|
||||
// ProgramStatusSupportEvent is emitted when the terminal replies to a Program
|
||||
// Status Protocol (OSC 7501) feature detection query, meaning the terminal
|
||||
// supports the protocol.
|
||||
//
|
||||
// See: https://www.superlogical.com/rex/docs/build/program-status
|
||||
type ProgramStatusSupportEvent struct{}
|
||||
|
||||
// ignoredEvent represents a sequence event that is ignored by the terminal
|
||||
// reader. This is used to ignore certain sequences that can be canceled.
|
||||
type ignoredEvent string
|
||||
+4
-81
@@ -98,53 +98,6 @@ func (s *StyledString) Bounds() Rectangle {
|
||||
|
||||
// printString draws a string starting at the given position. If s is nil, it
|
||||
// will build and return a slice of [Line]s instead (unwrapped, ignoring bounds).
|
||||
// passThrough reports whether a zero-width sequence should be carried in a
|
||||
// cell's content and replayed to the terminal, rather than dropped.
|
||||
//
|
||||
// The string-type sequences carry private data rather than driving the cursor:
|
||||
// APC for image protocols, DCS for device control, SOS and PM for whatever an
|
||||
// application agrees with its terminal. One means it to reach the terminal, and
|
||||
// a cell carrying one still advances by its own width, so the renderer's column
|
||||
// model still holds.
|
||||
//
|
||||
// Everything else is dropped, as it was before:
|
||||
//
|
||||
// - A CSI can move the cursor or erase part of the screen, and an ESC Fs
|
||||
// sequence can reset the terminal outright. Replaying one from inside a
|
||||
// cell would move the real cursor somewhere the model cannot see, which is
|
||||
// the drift a cell buffer exists to keep out.
|
||||
// - An OSC is dropped despite carrying data, because a cell is painted again
|
||||
// every time it changes and on every full repaint. A window title survives
|
||||
// that, but a clipboard write (OSC 52) or a notification (OSC 9) is not
|
||||
// something to fire again on each resize. Hyperlinks, the one OSC a cell
|
||||
// has a place for, are read into [Link] above.
|
||||
func passThrough[T []byte | string](seq T) bool {
|
||||
if !ansi.HasApcPrefix(seq) && !ansi.HasDcsPrefix(seq) &&
|
||||
!ansi.HasSosPrefix(seq) && !ansi.HasPmPrefix(seq) {
|
||||
return false
|
||||
}
|
||||
return terminated(seq)
|
||||
}
|
||||
|
||||
// terminated reports whether a string-type sequence ended with ST.
|
||||
//
|
||||
// The parser hands back whatever it has when the input runs out, so a string
|
||||
// that stops mid-sequence still arrives here. Carrying an unterminated
|
||||
// introducer into a cell would be worse than dropping it: the terminal would
|
||||
// swallow everything painted after that cell, looking for an end that never
|
||||
// comes.
|
||||
//
|
||||
// Only the two-byte ST is accepted. The one-byte C1 form, 0x9c, is also a
|
||||
// UTF-8 continuation byte, so a sequence that ran out partway through a
|
||||
// character such as U+071C ("\u071c", encoded dc 9c) ends in a byte
|
||||
// indistinguishable from a terminator. The parser and the terminal need not
|
||||
// resolve that ambiguity the same way, and guessing wrong reintroduces exactly
|
||||
// the swallowing this guards against.
|
||||
func terminated[T []byte | string](seq T) bool {
|
||||
n := len(seq)
|
||||
return n >= 2 && seq[n-1] == '\\' && seq[n-2] == ansi.ESC
|
||||
}
|
||||
|
||||
func printString[T []byte | string](
|
||||
s Screen,
|
||||
m WidthMethod,
|
||||
@@ -176,7 +129,6 @@ func printString[T []byte | string](
|
||||
var link Link
|
||||
var state byte
|
||||
lastX, lastY := -1, -1 // last cell written, for folding in combining marks
|
||||
var pending []byte // pass-through sequences awaiting a cell to ride on
|
||||
for len(str) > 0 {
|
||||
seq, width, n, newState := decoder(str, state, p)
|
||||
// The decoder's ASCII fast path doesn't check for trailing combining
|
||||
@@ -196,15 +148,6 @@ func printString[T []byte | string](
|
||||
case width > 0:
|
||||
cell.Width = width
|
||||
cell.Content = string(seq)
|
||||
// Any pass-through sequences seen since the last cell belong in
|
||||
// front of this glyph, the order they arrived in. Checked rather
|
||||
// than joined unconditionally, because there are none at all on the
|
||||
// overwhelmingly common path and the join is not free.
|
||||
if len(pending) > 0 {
|
||||
pending = append(pending, cell.Content...)
|
||||
cell.Content = string(pending)
|
||||
pending = pending[:0]
|
||||
}
|
||||
cell.Style = style
|
||||
cell.Link = link
|
||||
|
||||
@@ -289,8 +232,8 @@ func printString[T []byte | string](
|
||||
s.SetCell(lastX, lastY, &folded)
|
||||
}
|
||||
}
|
||||
case passThrough(seq):
|
||||
pending = append(pending, string(seq)...)
|
||||
default:
|
||||
cell.Content += string(seq)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,31 +241,13 @@ func printString[T []byte | string](
|
||||
state = newState
|
||||
str = str[n:]
|
||||
|
||||
if s != nil && y >= bounds.Max.Y {
|
||||
if y >= bounds.Max.Y {
|
||||
// We've reached the bottom of the bounds, stop processing further
|
||||
// lines.
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Pass-through sequences left at the end of the string have no glyph to
|
||||
// ride in front of, so fold them into the last cell written instead. The
|
||||
// alternative is a width-0 cell one past the content, which lands outside
|
||||
// the bounds whenever the string filled them.
|
||||
if len(pending) > 0 {
|
||||
if s == nil {
|
||||
if lastY >= 0 && lastY < len(lines) && lastX >= 0 && lastX < len(lines[lastY]) {
|
||||
lines[lastY][lastX].Content += string(pending)
|
||||
}
|
||||
} else if lastX >= 0 {
|
||||
if prev := s.CellAt(lastX, lastY); prev != nil {
|
||||
folded := *prev
|
||||
folded.Content += string(pending)
|
||||
s.SetCell(lastX, lastY, &folded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure to set the last cell if it's not empty.
|
||||
if !cell.IsZero() && s != nil {
|
||||
s.SetCell(x, y, &cell)
|
||||
@@ -440,9 +365,7 @@ func ReadStyle(params ansi.Params, pen *Style) {
|
||||
|
||||
// ReadLink reads a hyperlink escape sequence from a data buffer into link.
|
||||
func ReadLink(p []byte, link *Link) {
|
||||
// OSC 8 sequences have this structure `OSC 8 ; params ; URI ST`.
|
||||
// Only the first two semicolons are delimiters, semicolons that follow after are part of the URI.
|
||||
params := bytes.SplitN(p, []byte{';'}, 3)
|
||||
params := bytes.Split(p, []byte{';'})
|
||||
if len(params) != 3 {
|
||||
return
|
||||
}
|
||||
|
||||
+2
-16
@@ -191,28 +191,14 @@ func (t *Terminal) Start() error {
|
||||
}
|
||||
|
||||
// input loop
|
||||
//
|
||||
// The loop holds its own reference to the reader for its lifetime. Stop
|
||||
// sets t.pr to nil, and a loop that reads the field on every iteration
|
||||
// can evaluate it after that has happened and dereference nil. Cancel
|
||||
// and Close on the reader are what stop this loop; nilling the field is
|
||||
// only bookkeeping for a later Start.
|
||||
pr := t.pr
|
||||
t.errg.Go(func() error {
|
||||
for {
|
||||
n, err := pr.Read(t.buf)
|
||||
n, err := t.pr.Read(t.buf)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading terminal input: %w", err)
|
||||
}
|
||||
// Hand the event loop its own copy. t.buf is reused by the
|
||||
// next Read, and the receiver only copies out of the slice
|
||||
// after the send has completed, so sending t.buf[:n] lets
|
||||
// one read's bytes be overwritten by the next before they
|
||||
// have been consumed.
|
||||
data := make([]byte, n)
|
||||
copy(data, t.buf[:n])
|
||||
select {
|
||||
case bufc <- data:
|
||||
case bufc <- t.buf[:n]:
|
||||
case <-t.donec:
|
||||
return nil
|
||||
}
|
||||
|
||||
+1
-1
@@ -134,7 +134,7 @@ func (d *TerminalReader) serializeWin32InputRecords(records []xwindows.InputReco
|
||||
var button MouseButton
|
||||
alt := mevent.ControlKeyState&(xwindows.LEFT_ALT_PRESSED|xwindows.RIGHT_ALT_PRESSED) != 0
|
||||
ctrl := mevent.ControlKeyState&(xwindows.LEFT_CTRL_PRESSED|xwindows.RIGHT_CTRL_PRESSED) != 0
|
||||
shift := mevent.ControlKeyState&xwindows.SHIFT_PRESSED != 0
|
||||
shift := mevent.ControlKeyState&(xwindows.SHIFT_PRESSED) != 0
|
||||
wheelDirection := int16(highWord(mevent.ButtonState)) //nolint:gosec
|
||||
switch mevent.EventFlags {
|
||||
case 0, xwindows.DOUBLE_CLICK:
|
||||
|
||||
+111
-301
@@ -6,7 +6,6 @@ import (
|
||||
"hash/maphash"
|
||||
"io"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
@@ -145,11 +144,7 @@ type TerminalRenderer struct {
|
||||
clear bool // whether to force clear the screen
|
||||
caps capabilities // terminal control sequence capabilities
|
||||
atPhantom bool // whether the cursor is out of bounds and at a phantom cell
|
||||
noWrapLine bool // whether autowrap is off for the line currently being repainted
|
||||
driftRows []bool // rows holding a cell the terminal may measure differently
|
||||
termW, termH int // the size [TerminalRenderer.Resize] was last told
|
||||
termResized bool // whether the terminal changed size since the last render
|
||||
damaged []bool // rows this render disturbed itself, see [TerminalRenderer.damage]
|
||||
lineHadWide bool // whether the line currently being transformed contained a wide cell
|
||||
logger Logger // The logger used for debugging.
|
||||
|
||||
// profile is the color profile to use when downsampling colors. This is
|
||||
@@ -460,13 +455,7 @@ func (s *TerminalRenderer) move(newbuf *RenderBuffer, x, y int) {
|
||||
// }
|
||||
|
||||
if height > 0 {
|
||||
// Only clamp the remembered cursor row in fullscreen mode, where the
|
||||
// buffer covers the whole screen. In relative mode the terminal
|
||||
// screen extends beyond the frame buffer: after a shrink the
|
||||
// real cursor can legitimately sit below the new frame's last row,
|
||||
// and clamping the model here would skip the cursor-up move and leave
|
||||
// the old frame's top lines on screen.
|
||||
if !s.flags.Contains(tRelativeCursor) && s.cur.Y > height-1 {
|
||||
if s.cur.Y > height-1 {
|
||||
s.cur.Y = height - 1
|
||||
}
|
||||
if y > height-1 {
|
||||
@@ -503,33 +492,9 @@ func cellEqual(a, b *Cell) bool {
|
||||
}
|
||||
|
||||
// putCell draws a cell at the current cursor position.
|
||||
//
|
||||
// The lower right corner is written with autowrap off, since a pending wrap
|
||||
// there scrolls the screen as soon as anything else prints. A multi-codepoint
|
||||
// cluster is the exception: with autowrap off the terminal never advances past
|
||||
// the margin and reads the combining codepoints as belonging to the cell to the
|
||||
// left, so the cluster keeps autowrap on and the wrap is cancelled after.
|
||||
func (s *TerminalRenderer) putCell(newbuf *RenderBuffer, cell *Cell) {
|
||||
width, height := newbuf.Width(), newbuf.Height()
|
||||
atMargin := s.cur.X == width-1 && !s.noWrapLine
|
||||
lowerRight := atMargin && s.flags.Contains(tFullscreen) && s.cur.Y == height-1
|
||||
|
||||
// Only the corner cell can care, so the cluster is measured only there.
|
||||
if lowerRight && cell != nil && utf8.RuneCountInString(cell.Content) > 1 {
|
||||
s.putAttrCell(newbuf, cell)
|
||||
// The cluster kept autowrap on so it landed whole, which leaves the
|
||||
// cursor pending wrap. On the last row there is no next row to wrap
|
||||
// onto, so the next print scrolls the screen instead. Cancel it here
|
||||
// rather than leave the frame ending in a state the model denies.
|
||||
if s.atPhantom {
|
||||
_ = s.buf.WriteByte('\r')
|
||||
s.cur.X = 0
|
||||
s.atPhantom = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if lowerRight {
|
||||
if s.flags.Contains(tFullscreen) && s.cur.X == width-1 && s.cur.Y == height-1 {
|
||||
s.putCellLR(newbuf, cell)
|
||||
} else {
|
||||
s.putAttrCell(newbuf, cell)
|
||||
@@ -577,6 +542,10 @@ func (s *TerminalRenderer) putAttrCell(newbuf *RenderBuffer, cell *Cell) {
|
||||
if s.cur.X >= newbuf.Width() {
|
||||
s.atPhantom = true
|
||||
}
|
||||
|
||||
if cellWidth > 1 {
|
||||
s.lineHadWide = true
|
||||
}
|
||||
}
|
||||
|
||||
// putCellLR draws a cell at the lower right corner of the screen.
|
||||
@@ -596,7 +565,14 @@ func (s *TerminalRenderer) putCellLR(newbuf *RenderBuffer, cell *Cell) {
|
||||
// updatePen updates the cursor pen styles.
|
||||
func (s *TerminalRenderer) updatePen(cell *Cell) {
|
||||
if cell == nil {
|
||||
cell = &EmptyCell
|
||||
if !s.cur.Style.IsZero() {
|
||||
_, _ = s.buf.WriteString(ansi.ResetStyle)
|
||||
s.cur.Style = Style{} // Reset style
|
||||
}
|
||||
if !s.cur.Link.IsZero() {
|
||||
_, _ = s.buf.WriteString(ansi.ResetHyperlink())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Downsample pen when we don't have a [colorprofile.TrueColor],
|
||||
@@ -844,47 +820,22 @@ func lineHasDrift(m ansi.Method, line Line) bool {
|
||||
if c == nil || c.Width == 0 || len(c.Content) == 0 {
|
||||
continue
|
||||
}
|
||||
if c.Width > 1 {
|
||||
return true
|
||||
}
|
||||
// One printable ASCII byte is one column under every width model, so
|
||||
// it can never be a cell the models disagree about. Worth saying out
|
||||
// loud because working the width out means segmenting the content,
|
||||
// and this runs for every cell of every line the renderer diffs.
|
||||
if len(c.Content) == 1 && c.Content[0] >= 0x20 && c.Content[0] < 0x7f {
|
||||
continue
|
||||
}
|
||||
if m.StringWidth(c.Content) != ansi.StringWidth(c.Content) {
|
||||
if c.Width > 1 || m.StringWidth(c.Content) != ansi.StringWidth(c.Content) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// paintLine brings row y of the screen in line with newbuf. A drift-prone row,
|
||||
// or any row when force is set, is repainted; every other row is diffed.
|
||||
func (s *TerminalRenderer) paintLine(newbuf *RenderBuffer, y int, force bool) {
|
||||
drift := lineHasDrift(s.method, s.curbuf.Line(y)) || lineHasDrift(s.method, newbuf.Line(y))
|
||||
s.markDrift(y, newbuf.Height(), drift)
|
||||
switch {
|
||||
case drift && !s.flags.Contains(tGraphemeWidth):
|
||||
// Clip a cluster the terminal measures wider than the model instead of
|
||||
// letting it spill onto the next row, then put the cursor back.
|
||||
s.noWrapLine = true
|
||||
_, _ = s.buf.WriteString(ansi.ResetModeAutoWrap)
|
||||
s.repaintLine(newbuf, y)
|
||||
s.noWrapLine = false
|
||||
_, _ = s.buf.WriteString(ansi.SetModeAutoWrap)
|
||||
s.reanchorWideLine(newbuf)
|
||||
case drift || force:
|
||||
s.repaintLine(newbuf, y)
|
||||
default:
|
||||
s.transformLine(newbuf, y)
|
||||
}
|
||||
}
|
||||
|
||||
// repaintLine erases row y from column 0 and writes newbuf's cells, so the
|
||||
// result does not depend on what the model believes is on screen.
|
||||
// repaintLine repaints a line from scratch. Unlike
|
||||
// [TerminalRenderer.transformLine] it does not diff against the previous
|
||||
// frame: it erases the whole line from column 0 with [ansi.EraseLineRight]
|
||||
// and then writes the new frame's cells. Erasing from column 0 removes the
|
||||
// entire line including any wide cells the terminal painted at a different
|
||||
// width than the model measured, whose real extent the model cannot know.
|
||||
// The repaint then starts from a known-empty line and an absolute cursor
|
||||
// position, so the result does not depend on where the previous frame left
|
||||
// the cursor.
|
||||
func (s *TerminalRenderer) repaintLine(newbuf *RenderBuffer, y int) {
|
||||
oldLine := s.curbuf.Line(y)
|
||||
newLine := newbuf.Line(y)
|
||||
@@ -895,24 +846,12 @@ func (s *TerminalRenderer) repaintLine(newbuf *RenderBuffer, y int) {
|
||||
_, _ = s.buf.WriteString(ansi.EraseLineRight)
|
||||
s.cur.X = 0
|
||||
|
||||
// A cell whose width runs past the right margin cannot be shown, so stop
|
||||
// there. The buffer can hold one after a resize narrowed the line around
|
||||
// it, and painting it would put a clipped half-glyph on the screen.
|
||||
width := newbuf.Width()
|
||||
clipped := width
|
||||
for x := 0; x < width; x++ {
|
||||
if c := newLine.At(x); c != nil && !c.isWidePlaceholder() && x+c.Width > width {
|
||||
clipped = x
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Write the content cells. The line was just erased, so trailing blanks
|
||||
// need no write, and writing them would risk wrapping past the right
|
||||
// margin if the terminal painted a wide cell wider than the model
|
||||
// measured.
|
||||
last := -1
|
||||
for x := 0; x < clipped; x++ {
|
||||
for x := 0; x < newbuf.Width(); x++ {
|
||||
if c := newLine.At(x); c != nil && !c.isWidePlaceholder() && !cellEqual(c, blank) {
|
||||
last = x
|
||||
}
|
||||
@@ -921,40 +860,39 @@ func (s *TerminalRenderer) repaintLine(newbuf *RenderBuffer, y int) {
|
||||
s.putCell(newbuf, newLine.At(x))
|
||||
}
|
||||
|
||||
// Adopt the new line as the model of what is on screen. The clipped tail
|
||||
// was never painted, so the model records it as blank: claiming otherwise
|
||||
// would let a later frame skip the cell, and a resize that widens the line
|
||||
// would leave the half-glyph on screen forever.
|
||||
// Adopt the new line as the model of what is on screen.
|
||||
if len(oldLine) == len(newLine) {
|
||||
copy(oldLine, newLine)
|
||||
for x := clipped; x < width && x < len(oldLine); x++ {
|
||||
oldLine[x] = Cell{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// markDrift records whether a row holds a cell the terminal may measure
|
||||
// differently than the model, so a later width change knows which rows to
|
||||
// repaint. Recorded here rather than rescanned later, because the diff already
|
||||
// had to work it out.
|
||||
func (s *TerminalRenderer) markDrift(y, height int, drift bool) {
|
||||
if y < 0 {
|
||||
return
|
||||
}
|
||||
if n := max(height, y+1); len(s.driftRows) < n {
|
||||
s.driftRows = append(s.driftRows, make([]bool, n-len(s.driftRows))...)
|
||||
}
|
||||
s.driftRows[y] = drift
|
||||
}
|
||||
|
||||
// transformLine diffs row y against the model and writes only what changed,
|
||||
// using [ansi.ICH] and [ansi.DCH] to shift cells. It must not see a drift-prone
|
||||
// row; [TerminalRenderer.paintLine] routes those to a repaint.
|
||||
// transformLine transforms the given line in the current window to the
|
||||
// corresponding line in the new window. It uses [ansi.ICH] and [ansi.DCH] to
|
||||
// insert or delete characters.
|
||||
func (s *TerminalRenderer) transformLine(newbuf *RenderBuffer, y int) {
|
||||
var firstCell, oLastCell, nLastCell int // first, old last, new last index
|
||||
oldLine := s.curbuf.Line(y)
|
||||
newLine := newbuf.Line(y)
|
||||
|
||||
s.lineHadWide = false
|
||||
defer s.reanchorWideLine(newbuf)
|
||||
|
||||
// If either frame's line holds a cell that a cell-level diff cannot
|
||||
// safely reposition across, repaint the whole line instead. A wide cell
|
||||
// (width > 1) occupies several columns, so a diff that lands on a
|
||||
// continuation column splits the character, and per DEC semantics (also
|
||||
// implemented by ghostty and x/vt) an erase that splits a multi-cell
|
||||
// character erases the whole character, including a head cell that
|
||||
// belongs to the new frame. A cell whose width the terminal measures
|
||||
// differently than the model (an emoji cluster, a keycap) leaves the
|
||||
// real cursor at a column the model cannot predict, so a later erase in
|
||||
// the same transform fires at the wrong column. Repainting depends only
|
||||
// on an absolute cursor position and a known-empty line.
|
||||
if lineHasDrift(s.method, oldLine) || lineHasDrift(s.method, newLine) {
|
||||
s.repaintLine(newbuf, y)
|
||||
return
|
||||
}
|
||||
|
||||
// Find the first changed cell in the line
|
||||
blank := newLine.At(0)
|
||||
|
||||
@@ -1145,39 +1083,20 @@ func (s *TerminalRenderer) transformLine(newbuf *RenderBuffer, y int) {
|
||||
}
|
||||
}
|
||||
|
||||
// reanchorWideLine re-anchors the cursor after a line that may have left it
|
||||
// adrift. This is a best-effort fallback that bounds cursor desync to one line
|
||||
// on terminals whose width model disagrees with ours. When the terminal
|
||||
// negotiated Unicode grapheme width (mode 2027) the models agree, so no
|
||||
// re-anchor is needed.
|
||||
//
|
||||
// The row is re-anchored along with the column. A terminal that measures a
|
||||
// cluster wider than the model does runs past the right margin and wraps, so a
|
||||
// line the model believes fits can leave the real cursor a row further down.
|
||||
// Recovering the column alone would then leave every following line painted one
|
||||
// row off, which is the drift this re-anchor exists to contain.
|
||||
// reanchorWideLine re-anchors the cursor with a single absolute horizontal
|
||||
// move after a line that contained a wide cell. This is a best-effort fallback
|
||||
// that bounds cursor desync to one line on terminals whose width model
|
||||
// disagrees with ours. When the terminal negotiated Unicode grapheme width
|
||||
// (mode 2027) the models agree, so no re-anchor is needed.
|
||||
func (s *TerminalRenderer) reanchorWideLine(newbuf *RenderBuffer) {
|
||||
// In relative cursor mode there is no absolute row to move to, so the
|
||||
// column is all that can be recovered.
|
||||
if s.flags.Contains(tRelativeCursor) {
|
||||
if s.atPhantom || s.cur.X < 0 || s.cur.X >= newbuf.Width() {
|
||||
return
|
||||
}
|
||||
_, _ = s.buf.WriteString(ansi.CursorHorizontalAbsolute(s.cur.X + 1))
|
||||
if !s.lineHadWide || s.flags.Contains(tGraphemeWidth) {
|
||||
return
|
||||
}
|
||||
|
||||
if s.cur.X < 0 || s.cur.Y < 0 {
|
||||
s.lineHadWide = false
|
||||
if s.atPhantom || s.cur.X < 0 || s.cur.X >= newbuf.Width() {
|
||||
return
|
||||
}
|
||||
|
||||
// A pending wrap is as untrustworthy as the column: the terminal may have
|
||||
// already taken it. An absolute move cancels it and lands on a column the
|
||||
// model can name, so clamp into the line and record that.
|
||||
x := min(s.cur.X, newbuf.Width()-1)
|
||||
_, _ = s.buf.WriteString(ansi.CursorPosition(x+1, s.cur.Y+1))
|
||||
s.cur.X = x
|
||||
s.atPhantom = false
|
||||
_, _ = s.buf.WriteString(ansi.CursorHorizontalAbsolute(s.cur.X + 1))
|
||||
}
|
||||
|
||||
// deleteCells deletes the count cells at the current cursor position and moves
|
||||
@@ -1242,11 +1161,7 @@ func (s *TerminalRenderer) clearBottom(newbuf *RenderBuffer, total int) (top int
|
||||
}
|
||||
|
||||
if top < total {
|
||||
// top is the first row of the trailing blank region, so the
|
||||
// erase starts there. Starting a row earlier would wipe a row
|
||||
// this frame did not touch, and the loop that repaints only
|
||||
// touched rows would never put it back.
|
||||
s.move(newbuf, 0, top)
|
||||
s.move(newbuf, 0, max(0, top-1)) // top is 1-based
|
||||
s.clearToBottom(blank)
|
||||
if s.oldhash != nil && s.newhash != nil &&
|
||||
row < len(s.oldhash) && row < len(s.newhash) {
|
||||
@@ -1294,7 +1209,7 @@ func (s *TerminalRenderer) clearUpdate(newbuf *RenderBuffer) {
|
||||
}
|
||||
nonEmpty = s.clearBottom(newbuf, nonEmpty)
|
||||
for i := 0; i < nonEmpty && i < newbuf.Height(); i++ {
|
||||
s.paintLine(newbuf, i, false)
|
||||
s.transformLine(newbuf, i)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1324,97 +1239,6 @@ func (s *TerminalRenderer) Flush() (err error) {
|
||||
return
|
||||
}
|
||||
|
||||
// sizeChange is what a render owes to the screen or the frame having changed
|
||||
// shape since the last one.
|
||||
type sizeChange struct {
|
||||
// frameResized reports that the frame is a different shape than the model,
|
||||
// so the model has to be resized before anything is diffed against it.
|
||||
frameResized bool
|
||||
|
||||
// repaintAll requires every row to be painted whatever the model says.
|
||||
repaintAll bool
|
||||
}
|
||||
|
||||
// reconcileSize decides what a change of size costs, and is the only place that
|
||||
// decides it.
|
||||
//
|
||||
// A terminal that changed size rewrapped what was on it, and a frame that
|
||||
// changed shape leaves no row holding its old meaning. Either way the model
|
||||
// cannot be trusted. What differs is how much of the screen is the renderer's to
|
||||
// put right: fullscreen owns every cell and clears it, while an inline frame
|
||||
// shares the screen with whatever came before and so repaints its own rows and
|
||||
// leaves the rest alone.
|
||||
func (s *TerminalRenderer) reconcileSize(newbuf *RenderBuffer) sizeChange {
|
||||
newWidth, newHeight := newbuf.Width(), newbuf.Height()
|
||||
curWidth, curHeight := s.curbuf.Width(), s.curbuf.Height()
|
||||
fullscreen := s.flags.Contains(tFullscreen)
|
||||
|
||||
change := sizeChange{
|
||||
frameResized: curWidth != newWidth || curHeight != newHeight,
|
||||
}
|
||||
|
||||
if s.termResized {
|
||||
if fullscreen {
|
||||
s.clear = true
|
||||
} else {
|
||||
change.repaintAll = true
|
||||
}
|
||||
s.termResized = false
|
||||
}
|
||||
|
||||
if change.frameResized {
|
||||
// No row keeps its meaning, so the hashes that describe them are worth
|
||||
// nothing. Inline uses the narrower partial clear in Render instead.
|
||||
s.oldhash, s.newhash = nil, nil
|
||||
if fullscreen {
|
||||
s.clear = true
|
||||
}
|
||||
// Nor does the application's touch list, which never saw rows that
|
||||
// vanished and came back between two frames.
|
||||
s.damage(0, newHeight)
|
||||
}
|
||||
|
||||
// The terminal clips a row it measures wider than the model does, and the
|
||||
// model believes it painted the whole row. Changing the width changes what
|
||||
// fits, so a row holding a cell the two might measure differently has to be
|
||||
// painted again. Rows of plain cells are measured the same by everyone, so an
|
||||
// ASCII screen pays nothing.
|
||||
if !fullscreen && curWidth != newWidth {
|
||||
for y, drifts := range s.driftRows {
|
||||
if drifts {
|
||||
s.damage(y, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return change
|
||||
}
|
||||
|
||||
// damage marks n rows from y as rows this render disturbed on its own: rows it
|
||||
// scrolled, rows it erased, rows the terminal is about to measure differently
|
||||
// than the model does. The diff loop paints a damaged row whether or not the
|
||||
// application drew into it, because the application has no reason to redraw a
|
||||
// row it did not change and no way to know the renderer moved it.
|
||||
func (s *TerminalRenderer) damage(y, n int) {
|
||||
for i := max(y, 0); i < min(y+n, len(s.damaged)); i++ {
|
||||
s.damaged[i] = true
|
||||
}
|
||||
}
|
||||
|
||||
// beginDamage starts a fresh account, sized to the screen this render is about
|
||||
// to paint. A row outside it cannot be painted, so it cannot be damaged, and
|
||||
// clamping here is what lets every reader index the record without checking.
|
||||
// Reset on the way in rather than the way out, so no future exit path has to
|
||||
// remember to do it. The slice is kept when the size holds, so a steady stream
|
||||
// of frames allocates nothing.
|
||||
func (s *TerminalRenderer) beginDamage(height int) {
|
||||
if len(s.damaged) != height {
|
||||
s.damaged = make([]bool, height)
|
||||
return
|
||||
}
|
||||
clear(s.damaged)
|
||||
}
|
||||
|
||||
// Redraw forces a full redraw of the screen. It's equivalent to calling
|
||||
// [TerminalRenderer.Erase] and [TerminalRenderer.Render].
|
||||
func (s *TerminalRenderer) Redraw(newbuf *RenderBuffer) {
|
||||
@@ -1425,30 +1249,31 @@ func (s *TerminalRenderer) Redraw(newbuf *RenderBuffer) {
|
||||
// Render renders changes of the screen to the internal buffer. Call
|
||||
// [terminalWriter.Flush] to flush pending changes to the screen.
|
||||
func (s *TerminalRenderer) Render(newbuf *RenderBuffer) {
|
||||
// Do we need to render anything? A latched resize counts: the terminal moved
|
||||
// content the application cannot know about, so a frame it considers
|
||||
// unchanged still has to be put back on screen.
|
||||
// Do we need to render anything?
|
||||
touchedLines := newbuf.TouchedLines()
|
||||
if !s.clear && !s.termResized && touchedLines == 0 {
|
||||
if !s.clear && touchedLines == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Every reader below indexes the touch list by screen row, so the list has to
|
||||
// cover the screen before any of them run. Growing here is what lets them do
|
||||
// it without a length check each.
|
||||
newbuf.growTouched()
|
||||
|
||||
if s.curbuf == nil || s.curbuf.Bounds().Empty() {
|
||||
// Initialize the current buffer
|
||||
s.curbuf = NewRenderBuffer(newbuf.Width(), newbuf.Height())
|
||||
}
|
||||
|
||||
newWidth, newHeight := newbuf.Width(), newbuf.Height()
|
||||
curHeight := s.curbuf.Height()
|
||||
curWidth, curHeight := s.curbuf.Width(), s.curbuf.Height()
|
||||
|
||||
s.beginDamage(newHeight)
|
||||
|
||||
size := s.reconcileSize(newbuf)
|
||||
if curWidth != newWidth || curHeight != newHeight {
|
||||
s.oldhash, s.newhash = nil, nil
|
||||
// A shrink makes the terminal reflow in emulator-defined ways the
|
||||
// incremental model cannot predict; force a full repaint. Only in
|
||||
// fullscreen, where the renderer owns every cell it is about to
|
||||
// clear. Inline mode shares the screen with whatever came before,
|
||||
// so it uses the narrower partial clear below instead.
|
||||
if s.flags.Contains(tFullscreen) && (newWidth < curWidth || newHeight < curHeight) {
|
||||
s.clear = true
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Investigate whether this is necessary. Theoretically, terminals
|
||||
// can add/remove tab stops and we should be able to handle that. We could
|
||||
@@ -1463,37 +1288,29 @@ func (s *TerminalRenderer) Render(newbuf *RenderBuffer) {
|
||||
|
||||
var nonEmpty int
|
||||
|
||||
// An inline frame that gives up rows has to erase what it no longer covers,
|
||||
// or the tail of the taller frame stays on screen below the shorter one.
|
||||
// Fullscreen has no such rows: it repaints the whole screen instead.
|
||||
//
|
||||
// The erase runs from the new last row to the bottom of the screen, so it
|
||||
// reaches that residue wherever the terminal moved it. A width change at
|
||||
// the same time is therefore a reason to erase rather than a reason to skip
|
||||
// it, since a terminal that rewrapped those rows has spread them further
|
||||
// than the model can account for.
|
||||
// XXX: In inline mode, after a screen resize, we need to clear the extra
|
||||
// lines at the bottom of the screen. This is because in inline mode, we
|
||||
// don't use the full screen height and the current buffer size might be
|
||||
// larger than the new buffer size.
|
||||
partialClear := !s.flags.Contains(tFullscreen) && s.cur.X != -1 && s.cur.Y != -1 &&
|
||||
curWidth == newWidth &&
|
||||
curHeight > 0 &&
|
||||
curHeight > newHeight
|
||||
|
||||
if !s.clear && partialClear {
|
||||
// From the first row the frame gave up, so the frame's own rows are left
|
||||
// alone: nothing it still owns needs putting back, and a frame that
|
||||
// collapsed to nothing starts at its own first row rather than the one
|
||||
// above, which belongs to whatever shared the screen first.
|
||||
s.clearBelow(newbuf, nil, newHeight)
|
||||
s.clearBelow(newbuf, nil, newHeight-1)
|
||||
}
|
||||
|
||||
// Resize the model before diffing so the loop below walks every row
|
||||
// of the new screen, including rows added by a grow.
|
||||
if size.frameResized {
|
||||
if curWidth != newWidth || curHeight != newHeight {
|
||||
s.curbuf.Resize(newWidth, newHeight)
|
||||
}
|
||||
|
||||
if s.clear { //nolint:nestif
|
||||
s.clearUpdate(newbuf)
|
||||
s.clear = false
|
||||
} else if touchedLines > 0 || size.repaintAll {
|
||||
} else if touchedLines > 0 {
|
||||
// On Windows, there's a bug with Windows Terminal where [ansi.DECSTBM]
|
||||
// misbehaves and moves the cursor outside of the scrolling region. For
|
||||
// now, we disable the optimizations completely on Windows.
|
||||
@@ -1512,24 +1329,44 @@ func (s *TerminalRenderer) Render(newbuf *RenderBuffer) {
|
||||
|
||||
nonEmpty = s.clearBottom(newbuf, nonEmpty)
|
||||
for i = 0; i < nonEmpty; i++ {
|
||||
// A repaintAll row has to be put back whatever the model says.
|
||||
if size.repaintAll || s.damaged[i] || (newbuf.Touched[i] != nil &&
|
||||
if newbuf.Touched == nil || i >= len(newbuf.Touched) || (newbuf.Touched[i] != nil &&
|
||||
(newbuf.Touched[i].FirstCell != -1 || newbuf.Touched[i].LastCell != -1)) {
|
||||
s.paintLine(newbuf, i, size.repaintAll)
|
||||
s.transformLine(newbuf, i)
|
||||
changedLines++
|
||||
}
|
||||
|
||||
// Mark line changed successfully.
|
||||
if i < len(newbuf.Touched) && i <= newbuf.Height()-1 {
|
||||
newbuf.Touched[i] = &LineData{
|
||||
FirstCell: -1, LastCell: -1,
|
||||
}
|
||||
}
|
||||
if i < len(s.curbuf.Touched) && i < s.curbuf.Height()-1 {
|
||||
s.curbuf.Touched[i] = &LineData{
|
||||
FirstCell: -1, LastCell: -1,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !s.flags.Contains(tFullscreen) && size.frameResized {
|
||||
s.move(newbuf, 0, max(newHeight-1, 0))
|
||||
if !s.flags.Contains(tFullscreen) && (curWidth != newWidth || curHeight != newHeight) {
|
||||
s.move(newbuf, 0, newHeight-1)
|
||||
}
|
||||
|
||||
// Sync windows and screen
|
||||
newbuf.growTouched()
|
||||
resetTouched(newbuf.Touched)
|
||||
newbuf.Touched = make([]*LineData, newHeight)
|
||||
for i := range newbuf.Touched {
|
||||
newbuf.Touched[i] = &LineData{
|
||||
FirstCell: -1, LastCell: -1,
|
||||
}
|
||||
}
|
||||
for i := range s.curbuf.Touched {
|
||||
s.curbuf.Touched[i] = &LineData{
|
||||
FirstCell: -1, LastCell: -1,
|
||||
}
|
||||
}
|
||||
|
||||
s.updatePen(nil)
|
||||
s.updatePen(nil) // nil indicates a blank cell with no styles
|
||||
}
|
||||
|
||||
// Erase marks the screen to be fully erased on the next render.
|
||||
@@ -1548,25 +1385,10 @@ func (s *TerminalRenderer) Erase() {
|
||||
// -1 there means "first move, assume the origin" rather than "unknown", so
|
||||
// invalidating would assert a position instead of forgetting one. Keep the
|
||||
// old model in that mode and let the next render diff against it.
|
||||
//
|
||||
// A resize also forces the next render to repaint, since the screen moves
|
||||
// content around to fit and the model cannot see where it went. Whether that
|
||||
// means the whole screen or only the frame's own rows is decided in
|
||||
// [TerminalRenderer.Render].
|
||||
func (s *TerminalRenderer) Resize(width, height int) {
|
||||
func (s *TerminalRenderer) Resize(width, _ int) {
|
||||
if s.tabs != nil {
|
||||
s.tabs.Resize(width)
|
||||
}
|
||||
|
||||
// Latched against the size last reported, not against the model, because
|
||||
// Render sees only the size the application draws at: a screen that shrinks
|
||||
// and grows back between two frames looks unchanged by the time it gets
|
||||
// there. What the resize costs is decided in Render, which knows the mode.
|
||||
if width > 0 && height > 0 {
|
||||
s.termResized = s.termResized || (s.termW > 0 && (width != s.termW || height != s.termH))
|
||||
s.termW, s.termH = width, height
|
||||
}
|
||||
|
||||
if !s.flags.Contains(tRelativeCursor) {
|
||||
s.cur.X, s.cur.Y = -1, -1
|
||||
}
|
||||
@@ -1902,15 +1724,3 @@ func xtermCaps(termtype string) (v capabilities) {
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
// resetTouched marks every line as untouched, reusing the records already there
|
||||
// rather than allocating a new one per line on every render.
|
||||
func resetTouched(touched []*LineData) {
|
||||
for i, ld := range touched {
|
||||
if ld == nil {
|
||||
touched[i] = &LineData{FirstCell: -1, LastCell: -1}
|
||||
continue
|
||||
}
|
||||
ld.FirstCell, ld.LastCell = -1, -1
|
||||
}
|
||||
}
|
||||
+19
-5
@@ -110,11 +110,6 @@ func (s *TerminalRenderer) scrolln(newbuf *RenderBuffer, n, top, bot, maxY int)
|
||||
|
||||
s.scrollBuffer(s.curbuf, n, top, bot, blank)
|
||||
|
||||
// Every row in the range moved, whether or not the application drew into
|
||||
// it. A row the scroll already put in the right place costs a comparison
|
||||
// and no output, which is the whole point of scrolling in hardware.
|
||||
s.damage(top, bot-top+1)
|
||||
|
||||
// shift hash values too, they can be reused
|
||||
s.scrollOldhash(n, top, bot)
|
||||
|
||||
@@ -149,6 +144,25 @@ func (s *TerminalRenderer) scrollBuffer(b *RenderBuffer, n, top, bot int, blank
|
||||
b.FillArea(blank, Rect(0, line, b.Width(), 1))
|
||||
}
|
||||
}
|
||||
|
||||
s.touchLine(b, top, bot-top+1, true)
|
||||
}
|
||||
|
||||
// touchLine marks the line as touched.
|
||||
func (s *TerminalRenderer) touchLine(newbuf *RenderBuffer, y, n int, changed bool) {
|
||||
height := newbuf.Height()
|
||||
if n < 0 || y < 0 || y >= height || newbuf.Touched == nil || len(newbuf.Touched) < height {
|
||||
return // Nothing to touch
|
||||
}
|
||||
|
||||
width := newbuf.Width()
|
||||
for i := y; i < y+n && i < height && i < len(newbuf.Touched); i++ {
|
||||
if changed {
|
||||
newbuf.TouchLine(0, i, width)
|
||||
} else {
|
||||
newbuf.Touched[i] = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// scrollUp scrolls the screen up by n lines.
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ func (s *TerminalRenderer) updateHashmap(newbuf *RenderBuffer) {
|
||||
if len(s.oldhash) >= height && len(s.newhash) >= height {
|
||||
// rehash changed lines
|
||||
for i := range height {
|
||||
if newbuf.Touched[i] != nil {
|
||||
if newbuf.Touched == nil || newbuf.Touched[i] != nil {
|
||||
// TODO: Investigate why this is needed. If we remove this
|
||||
// line, scroll optimization does not work correctly. This
|
||||
// should happen else where.
|
||||
|
||||
+1
-26
@@ -35,7 +35,6 @@ type TerminalScreen struct {
|
||||
backgroundColor color.Color
|
||||
foregroundColor color.Color
|
||||
progressBar *ProgressBar
|
||||
programStatus *ProgramStatus
|
||||
windowTitle string
|
||||
syncUpdates bool // mode 2026
|
||||
resetTabs bool // DECST8C - reset terminal tabs on start
|
||||
@@ -206,6 +205,7 @@ func (s *TerminalScreen) Resize(width, height int) {
|
||||
s.rbuf.Resize(width, height)
|
||||
s.rend.Resize(width, height)
|
||||
s.rend.Erase()
|
||||
s.rbuf.Touched = nil
|
||||
}
|
||||
|
||||
// Display clears the screen and draws the given [Drawable] onto the terminal
|
||||
@@ -621,24 +621,6 @@ func (s *TerminalScreen) ProgressBar() *ProgressBar {
|
||||
return s.progressBar
|
||||
}
|
||||
|
||||
// SetProgramStatus reports the program status to the terminal using the
|
||||
// Program Status Protocol (OSC 7501). A nil status removes every program
|
||||
// status record on the terminal.
|
||||
//
|
||||
// The changes can be committed to the underlying writer by calling the
|
||||
// [TerminalScreen.Flush] method.
|
||||
func (s *TerminalScreen) SetProgramStatus(ps *ProgramStatus) {
|
||||
EncodeProgramStatus(s.buf, ps) //nolint:errcheck
|
||||
s.programStatus = ps
|
||||
}
|
||||
|
||||
// ProgramStatus returns the last program status reported to the terminal.
|
||||
//
|
||||
// A nil value indicates that no program status is currently set.
|
||||
func (s *TerminalScreen) ProgramStatus() *ProgramStatus {
|
||||
return s.programStatus
|
||||
}
|
||||
|
||||
// Reset resets the terminal screen to its default state, clearing the screen,
|
||||
// switching back to the main screen buffer if necessary, and resetting all
|
||||
// terminal settings to their defaults.
|
||||
@@ -692,10 +674,6 @@ func (s *TerminalScreen) Reset() {
|
||||
if s.progressBar != nil && s.progressBar.State != ProgressBarNone {
|
||||
sb.WriteString(ansi.ResetProgressBar)
|
||||
}
|
||||
// The program status is deliberately left in place. The Program Status
|
||||
// Protocol expects done and error records to outlive the program, and
|
||||
// terminals drop working and blocked records on their own when the
|
||||
// process exits or the next shell prompt starts.
|
||||
|
||||
s.buf.WriteString(sb.String())
|
||||
|
||||
@@ -764,9 +742,6 @@ func (s *TerminalScreen) Restore() {
|
||||
if s.progressBar != nil && s.progressBar.State != ProgressBarNone {
|
||||
EncodeProgressBar(&sb, s.progressBar)
|
||||
}
|
||||
if s.programStatus != nil {
|
||||
EncodeProgramStatus(&sb, s.programStatus) //nolint:errcheck
|
||||
}
|
||||
|
||||
s.buf.WriteString(sb.String())
|
||||
|
||||
|
||||
+1
-47
@@ -25,7 +25,7 @@ type Drawable interface {
|
||||
// DrawableFunc is a function that implements the [Drawable] interface.
|
||||
type DrawableFunc func(scr Screen, rect Rectangle)
|
||||
|
||||
var _ Drawable = DrawableFunc(nil)
|
||||
var _ Drawable = (DrawableFunc)(nil)
|
||||
|
||||
// Draw implements the [Drawable] interface.
|
||||
func (f DrawableFunc) Draw(scr Screen, rect Rectangle) {
|
||||
@@ -382,52 +382,6 @@ func EncodeProgressBar(w io.Writer, pb *ProgressBar) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProgramStatus is a Program Status Protocol (OSC 7501) report.
|
||||
//
|
||||
// See: https://www.superlogical.com/rex/docs/build/program-status
|
||||
type ProgramStatus = ansi.ProgramStatus
|
||||
|
||||
// ProgramState is the state of a [ProgramStatus].
|
||||
type ProgramState = ansi.ProgramState
|
||||
|
||||
// ProgramStatusKind says what a blocked program waits for.
|
||||
type ProgramStatusKind = ansi.ProgramStatusKind
|
||||
|
||||
// Program states.
|
||||
const (
|
||||
ProgramStateIdle = ansi.ProgramStateIdle
|
||||
ProgramStateWorking = ansi.ProgramStateWorking
|
||||
ProgramStateDone = ansi.ProgramStateDone
|
||||
ProgramStateBlocked = ansi.ProgramStateBlocked
|
||||
ProgramStateError = ansi.ProgramStateError
|
||||
ProgramStateClear = ansi.ProgramStateClear
|
||||
)
|
||||
|
||||
// Program status kinds.
|
||||
const (
|
||||
ProgramStatusKindPermission = ansi.ProgramStatusKindPermission
|
||||
ProgramStatusKindQuestion = ansi.ProgramStatusKindQuestion
|
||||
ProgramStatusKindAuth = ansi.ProgramStatusKindAuth
|
||||
)
|
||||
|
||||
// EncodeProgramStatus encodes the program status to the given writer. A nil
|
||||
// status removes every program status record on the terminal.
|
||||
func EncodeProgramStatus(w io.Writer, ps *ProgramStatus) error {
|
||||
seq := ansi.ClearProgramStatus
|
||||
if ps != nil {
|
||||
seq = ansi.SetProgramStatus(*ps)
|
||||
if seq == "" {
|
||||
return fmt.Errorf("invalid program status: state=%q id=%q", ps.State, ps.ID)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := io.WriteString(w, seq); err != nil {
|
||||
return fmt.Errorf("failed to set program status: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncodeKeyboardEnhancements encodes the keyboard enhancements to the given
|
||||
// writer.
|
||||
func EncodeKeyboardEnhancements(w io.Writer, ke *KeyboardEnhancements) error {
|
||||
|
||||
-234
@@ -1,234 +0,0 @@
|
||||
package ansi
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// ProgramState is the state of a program reported with the Program Status
|
||||
// Protocol (OSC 7501).
|
||||
//
|
||||
// See: https://www.superlogical.com/rex/docs/build/program-status
|
||||
type ProgramState string
|
||||
|
||||
// Program states.
|
||||
const (
|
||||
// ProgramStateIdle means the program is at rest, waiting for the user's
|
||||
// next instruction.
|
||||
ProgramStateIdle ProgramState = "idle"
|
||||
// ProgramStateWorking means the program is running.
|
||||
ProgramStateWorking ProgramState = "working"
|
||||
// ProgramStateDone means the program finished a piece of work and the
|
||||
// result is ready to look at.
|
||||
ProgramStateDone ProgramState = "done"
|
||||
// ProgramStateBlocked means the program cannot continue until the user
|
||||
// does something.
|
||||
ProgramStateBlocked ProgramState = "blocked"
|
||||
// ProgramStateError means the program failed and stopped.
|
||||
ProgramStateError ProgramState = "error"
|
||||
// ProgramStateClear removes the addressed record and every record beneath
|
||||
// it. With no id, it removes every record on the terminal.
|
||||
ProgramStateClear ProgramState = "clear"
|
||||
)
|
||||
|
||||
// ProgramStatusKind says what a blocked program is waiting for.
|
||||
type ProgramStatusKind string
|
||||
|
||||
// Program status kinds.
|
||||
const (
|
||||
// ProgramStatusKindPermission means the program waits for approval to do
|
||||
// something.
|
||||
ProgramStatusKindPermission ProgramStatusKind = "permission"
|
||||
// ProgramStatusKindQuestion means the user must type an answer.
|
||||
ProgramStatusKindQuestion ProgramStatusKind = "question"
|
||||
// ProgramStatusKindAuth means the program waits for a login, token, or
|
||||
// credential.
|
||||
ProgramStatusKindAuth ProgramStatusKind = "auth"
|
||||
)
|
||||
|
||||
// ProgramStatus is a report of the Program Status Protocol (OSC 7501).
|
||||
//
|
||||
// Each report replaces its record completely, so fields such as App and Title
|
||||
// should be included in every report.
|
||||
//
|
||||
// See: https://www.superlogical.com/rex/docs/build/program-status
|
||||
type ProgramStatus struct {
|
||||
// State is the program state. Required.
|
||||
State ProgramState
|
||||
// ID addresses a record. Empty means the root record. It is a "/"
|
||||
// separated path of segments matching [A-Za-z0-9_.+-]{1,32}.
|
||||
ID string
|
||||
// App is a stable machine-readable program name matching
|
||||
// [A-Za-z0-9_.+-]{1,32}. Invalid values are omitted.
|
||||
App string
|
||||
// Kind says what a blocked program waits for. Only used with
|
||||
// [ProgramStateBlocked].
|
||||
Kind ProgramStatusKind
|
||||
// Progress is a percentage between 0 and 100. Only used with
|
||||
// [ProgramStateWorking] and [ProgramStateBlocked] when HasProgress is
|
||||
// true. Otherwise progress is indeterminate.
|
||||
Progress int
|
||||
// HasProgress reports whether Progress is set.
|
||||
HasProgress bool
|
||||
// Title is a short human-readable label for the record.
|
||||
Title string
|
||||
// Message is one human-readable line describing the record.
|
||||
Message string
|
||||
}
|
||||
|
||||
// Program Status Protocol limits on decoded text.
|
||||
const (
|
||||
programStatusMaxTitle = 192
|
||||
programStatusMaxMessage = 2048
|
||||
)
|
||||
|
||||
// ClearProgramStatus is a sequence that removes every program status record
|
||||
// on the terminal.
|
||||
//
|
||||
// OSC 7501 ; state=clear BEL
|
||||
//
|
||||
// See: https://www.superlogical.com/rex/docs/build/program-status
|
||||
const ClearProgramStatus = "\x1b]7501;state=clear\x07"
|
||||
|
||||
// RequestProgramStatusSupport is a sequence that asks the terminal whether it
|
||||
// supports the Program Status Protocol. A supporting terminal replies with
|
||||
// OSC 7501 ; ? ST.
|
||||
//
|
||||
// OSC 7501 ; ? BEL
|
||||
//
|
||||
// See: https://www.superlogical.com/rex/docs/build/program-status
|
||||
const RequestProgramStatusSupport = "\x1b]7501;?\x07"
|
||||
|
||||
// ClearProgramStatusID returns a sequence that removes the record with the
|
||||
// given id and every record beneath it. An empty id removes every record. It
|
||||
// returns an empty string if the id is invalid.
|
||||
//
|
||||
// OSC 7501 ; state=clear:id=Id BEL
|
||||
//
|
||||
// See: https://www.superlogical.com/rex/docs/build/program-status
|
||||
func ClearProgramStatusID(id string) string {
|
||||
return SetProgramStatus(ProgramStatus{State: ProgramStateClear, ID: id})
|
||||
}
|
||||
|
||||
// SetProgramStatus returns a sequence that reports a program status using the
|
||||
// Program Status Protocol (OSC 7501).
|
||||
//
|
||||
// OSC 7501 ; key=value:key=value BEL
|
||||
//
|
||||
// Terminals discard a whole report when any part of it is invalid, so the
|
||||
// report is sanitized: control characters in Title and Message are replaced
|
||||
// with spaces and the text is truncated to the protocol limits, and an invalid
|
||||
// App, or a Kind or Progress used with the wrong state, is omitted. It returns
|
||||
// an empty string if the state is unknown or the id is invalid.
|
||||
//
|
||||
// See: https://www.superlogical.com/rex/docs/build/program-status
|
||||
func SetProgramStatus(s ProgramStatus) string {
|
||||
switch s.State {
|
||||
case ProgramStateIdle, ProgramStateWorking, ProgramStateDone,
|
||||
ProgramStateBlocked, ProgramStateError, ProgramStateClear:
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
if s.ID != "" && !validProgramStatusID(s.ID) {
|
||||
return ""
|
||||
}
|
||||
|
||||
pairs := []string{"state=" + string(s.State)}
|
||||
if s.ID != "" {
|
||||
pairs = append(pairs, "id="+s.ID)
|
||||
}
|
||||
if s.State == ProgramStateClear {
|
||||
return programStatusSequence(pairs)
|
||||
}
|
||||
if s.State == ProgramStateBlocked && validProgramStatusKind(s.Kind) {
|
||||
pairs = append(pairs, "kind="+string(s.Kind))
|
||||
}
|
||||
if s.HasProgress && (s.State == ProgramStateWorking || s.State == ProgramStateBlocked) {
|
||||
pairs = append(pairs, "progress="+strconv.Itoa(min(max(s.Progress, 0), 100)))
|
||||
}
|
||||
if validProgramStatusSegment(s.App) {
|
||||
pairs = append(pairs, "app="+s.App)
|
||||
}
|
||||
if t := programStatusText(s.Title, programStatusMaxTitle); t != "" {
|
||||
pairs = append(pairs, "title="+t)
|
||||
}
|
||||
if m := programStatusText(s.Message, programStatusMaxMessage); m != "" {
|
||||
pairs = append(pairs, "msg="+m)
|
||||
}
|
||||
|
||||
return programStatusSequence(pairs)
|
||||
}
|
||||
|
||||
func programStatusSequence(pairs []string) string {
|
||||
return "\x1b]7501;" + strings.Join(pairs, ":") + "\x07"
|
||||
}
|
||||
|
||||
func validProgramStatusKind(k ProgramStatusKind) bool {
|
||||
switch k {
|
||||
case ProgramStatusKindPermission, ProgramStatusKindQuestion, ProgramStatusKindAuth:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// programStatusText sanitizes, truncates, and base64 encodes free text.
|
||||
func programStatusText(s string, limit int) string {
|
||||
s = strings.ToValidUTF8(s, "\uFFFD")
|
||||
var sb strings.Builder
|
||||
space := false
|
||||
for _, r := range s {
|
||||
if r < 0x20 || (r >= 0x7f && r <= 0x9f) {
|
||||
if !space {
|
||||
sb.WriteByte(' ')
|
||||
space = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
space = false
|
||||
sb.WriteRune(r)
|
||||
}
|
||||
s = strings.TrimSpace(sb.String())
|
||||
if len(s) > limit {
|
||||
s = s[:limit]
|
||||
for len(s) > 0 && !utf8.ValidString(s) {
|
||||
s = s[:len(s)-1]
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
}
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString([]byte(s))
|
||||
}
|
||||
|
||||
func validProgramStatusID(id string) bool {
|
||||
if len(id) > 128 {
|
||||
return false
|
||||
}
|
||||
segs := strings.Split(id, "/")
|
||||
if len(segs) > 8 {
|
||||
return false
|
||||
}
|
||||
for _, seg := range segs {
|
||||
if !validProgramStatusSegment(seg) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validProgramStatusSegment(s string) bool {
|
||||
if len(s) < 1 || len(s) > 32 {
|
||||
return false
|
||||
}
|
||||
for i := range len(s) {
|
||||
c := s[i]
|
||||
if (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') && (c < '0' || c > '9') &&
|
||||
c != '_' && c != '.' && c != '+' && c != '-' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
+1
@@ -1,4 +1,5 @@
|
||||
//go:build !go1.21
|
||||
// +build !go1.21
|
||||
|
||||
/*
|
||||
Copyright 2019 The logr Authors.
|
||||
|
||||
+1
@@ -1,4 +1,5 @@
|
||||
//go:build go1.21
|
||||
// +build go1.21
|
||||
|
||||
/*
|
||||
Copyright 2019 The logr Authors.
|
||||
|
||||
+11
-36
@@ -426,7 +426,7 @@ func (f Formatter) colon() byte {
|
||||
}
|
||||
|
||||
func (f Formatter) pretty(value any) string {
|
||||
return f.prettyWithFlags(value, 0, 0, 0, nil)
|
||||
return f.prettyWithFlags(value, 0, 0)
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -434,13 +434,7 @@ const (
|
||||
)
|
||||
|
||||
// TODO: This is not fast. Most of the overhead goes here.
|
||||
// value: The value to render
|
||||
// flags: Bitmask of flags (see above)
|
||||
// depth: The current depth of nested structs, slices, arrays, and maps
|
||||
// ptrDepth: The current depth of including pointer dereferences
|
||||
// ptrMap: A map of pointers already seen, to avoid infinite recursion (usually
|
||||
// nil unless ptrDepth is large)
|
||||
func (f Formatter) prettyWithFlags(value any, flags uint32, depth int, ptrDepth int, ptrMap map[uintptr]bool) string {
|
||||
func (f Formatter) prettyWithFlags(value any, flags uint32, depth int) string {
|
||||
if depth > f.opts.MaxLogDepth {
|
||||
return `"<max-log-depth-exceeded>"`
|
||||
}
|
||||
@@ -510,7 +504,7 @@ func (f Formatter) prettyWithFlags(value any, flags uint32, depth int, ptrDepth
|
||||
// arbitrary keys might need escaping
|
||||
buf.WriteString(prettyString(k))
|
||||
buf.WriteByte(f.colon())
|
||||
buf.WriteString(f.prettyWithFlags(v[i+1], 0, depth+1, ptrDepth+1, ptrMap))
|
||||
buf.WriteString(f.prettyWithFlags(v[i+1], 0, depth+1))
|
||||
}
|
||||
if flags&flagRawStruct == 0 {
|
||||
buf.WriteByte('}')
|
||||
@@ -582,7 +576,7 @@ func (f Formatter) prettyWithFlags(value any, flags uint32, depth int, ptrDepth
|
||||
}
|
||||
printComma = true // if we got here, we are rendering a field
|
||||
if fld.Anonymous && fld.Type.Kind() == reflect.Struct && name == "" {
|
||||
buf.WriteString(f.prettyWithFlags(v.Field(i).Interface(), flags|flagRawStruct, depth+1, ptrDepth+1, ptrMap))
|
||||
buf.WriteString(f.prettyWithFlags(v.Field(i).Interface(), flags|flagRawStruct, depth+1))
|
||||
continue
|
||||
}
|
||||
if name == "" {
|
||||
@@ -591,7 +585,7 @@ func (f Formatter) prettyWithFlags(value any, flags uint32, depth int, ptrDepth
|
||||
// field names can't contain characters which need escaping
|
||||
buf.WriteString(f.quoted(name, false))
|
||||
buf.WriteByte(f.colon())
|
||||
buf.WriteString(f.prettyWithFlags(v.Field(i).Interface(), 0, depth+1, ptrDepth+1, ptrMap))
|
||||
buf.WriteString(f.prettyWithFlags(v.Field(i).Interface(), 0, depth+1))
|
||||
}
|
||||
if flags&flagRawStruct == 0 {
|
||||
buf.WriteByte('}')
|
||||
@@ -618,7 +612,7 @@ func (f Formatter) prettyWithFlags(value any, flags uint32, depth int, ptrDepth
|
||||
buf.WriteByte(f.comma())
|
||||
}
|
||||
e := v.Index(i)
|
||||
buf.WriteString(f.prettyWithFlags(e.Interface(), 0, depth+1, ptrDepth+1, ptrMap))
|
||||
buf.WriteString(f.prettyWithFlags(e.Interface(), 0, depth+1))
|
||||
}
|
||||
buf.WriteByte(']')
|
||||
return buf.String()
|
||||
@@ -643,8 +637,7 @@ func (f Formatter) prettyWithFlags(value any, flags uint32, depth int, ptrDepth
|
||||
keystr = prettyString(keystr)
|
||||
} else {
|
||||
// prettyWithFlags will produce already-escaped values
|
||||
// key depth is unrelated to overall depth
|
||||
keystr = f.prettyWithFlags(it.Key().Interface(), 0, 0, ptrDepth, ptrMap)
|
||||
keystr = f.prettyWithFlags(it.Key().Interface(), 0, depth+1)
|
||||
if t.Key().Kind() != reflect.String {
|
||||
// JSON only does string keys. Unlike Go's standard JSON, we'll
|
||||
// convert just about anything to a string.
|
||||
@@ -653,34 +646,16 @@ func (f Formatter) prettyWithFlags(value any, flags uint32, depth int, ptrDepth
|
||||
}
|
||||
buf.WriteString(keystr)
|
||||
buf.WriteByte(f.colon())
|
||||
buf.WriteString(f.prettyWithFlags(it.Value().Interface(), 0, depth+1, ptrDepth+1, ptrMap))
|
||||
buf.WriteString(f.prettyWithFlags(it.Value().Interface(), 0, depth+1))
|
||||
i++
|
||||
}
|
||||
buf.WriteByte('}')
|
||||
return buf.String()
|
||||
case reflect.Pointer, reflect.Interface:
|
||||
case reflect.Ptr, reflect.Interface:
|
||||
if v.IsNil() {
|
||||
return "null"
|
||||
}
|
||||
// Special case: recursive pointers. For normal use we do not want to
|
||||
// count pointer dereferences as depth, but if we see the same pointer
|
||||
// again we have a recursion and need to stop. After a large number of
|
||||
// pointer dereferences we will start tracking pointers to avoid the
|
||||
// perf hit of doing it in the normal path.
|
||||
//
|
||||
// This should not happen accidentally (e.g. json decoding should never
|
||||
// do this) but we can handle it gracefully.
|
||||
if ptrMap != nil && ptrMap[uintptr(v.Pointer())] {
|
||||
depth = f.opts.MaxLogDepth + 1 // force a depth error
|
||||
}
|
||||
const maxDepthFactor = 4 // arbitrary, but we want it large enough to not false-alert
|
||||
if ptrDepth > f.opts.MaxLogDepth*maxDepthFactor && ptrMap == nil {
|
||||
ptrMap = map[uintptr]bool{}
|
||||
}
|
||||
if ptrMap != nil {
|
||||
ptrMap[(uintptr)(v.Pointer())] = true
|
||||
}
|
||||
return f.prettyWithFlags(v.Elem().Interface(), 0, depth, ptrDepth+1, ptrMap)
|
||||
return f.prettyWithFlags(v.Elem().Interface(), 0, depth)
|
||||
}
|
||||
return fmt.Sprintf(`"<unhandled-%s>"`, t.Kind().String())
|
||||
}
|
||||
@@ -722,7 +697,7 @@ func isEmpty(v reflect.Value) bool {
|
||||
return v.Float() == 0
|
||||
case reflect.Complex64, reflect.Complex128:
|
||||
return v.Complex() == 0
|
||||
case reflect.Interface, reflect.Pointer:
|
||||
case reflect.Interface, reflect.Ptr:
|
||||
return v.IsNil()
|
||||
}
|
||||
return false
|
||||
|
||||
+6
-16
@@ -1,4 +1,5 @@
|
||||
//go:build go1.21
|
||||
// +build go1.21
|
||||
|
||||
/*
|
||||
Copyright 2023 The logr Authors.
|
||||
@@ -32,7 +33,7 @@ const extraSlogSinkDepth = 3 // 2 for slog, 1 for SlogSink
|
||||
func (l fnlogger) Handle(_ context.Context, record slog.Record) error {
|
||||
kvList := make([]any, 0, 2*record.NumAttrs())
|
||||
record.Attrs(func(attr slog.Attr) bool {
|
||||
kvList = attrToKVs(attr, kvList, l.opts.MaxLogDepth)
|
||||
kvList = attrToKVs(attr, kvList)
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -48,7 +49,7 @@ func (l fnlogger) Handle(_ context.Context, record slog.Record) error {
|
||||
func (l fnlogger) WithAttrs(attrs []slog.Attr) logr.SlogSink {
|
||||
kvList := make([]any, 0, 2*len(attrs))
|
||||
for _, attr := range attrs {
|
||||
kvList = attrToKVs(attr, kvList, l.opts.MaxLogDepth)
|
||||
kvList = attrToKVs(attr, kvList)
|
||||
}
|
||||
l.AddValues(kvList)
|
||||
return &l
|
||||
@@ -60,25 +61,14 @@ func (l fnlogger) WithGroup(name string) logr.SlogSink {
|
||||
}
|
||||
|
||||
// attrToKVs appends a slog.Attr to a logr-style kvList. It handle slog Groups
|
||||
// and other details of slog. maxDepth bounds recursion into nested groups so a
|
||||
// deeply-nested slog.Group cannot exhaust the stack; it is decremented per group
|
||||
// level and starts at the Formatter's MaxLogDepth (past which the formatter would
|
||||
// truncate the rendering anyway).
|
||||
func attrToKVs(attr slog.Attr, kvList []any, maxDepth int) []any {
|
||||
// and other details of slog.
|
||||
func attrToKVs(attr slog.Attr, kvList []any) []any {
|
||||
attrVal := attr.Value.Resolve()
|
||||
if attrVal.Kind() == slog.KindGroup {
|
||||
if maxDepth <= 0 {
|
||||
// Nesting is too deep to build without risking a stack overflow.
|
||||
// Stop here; the formatter truncates below MaxLogDepth regardless.
|
||||
if attr.Key != "" {
|
||||
kvList = append(kvList, attr.Key, "<max-log-depth-exceeded>")
|
||||
}
|
||||
return kvList
|
||||
}
|
||||
groupVal := attrVal.Group()
|
||||
grpKVs := make([]any, 0, 2*len(groupVal))
|
||||
for _, attr := range groupVal {
|
||||
grpKVs = attrToKVs(attr, grpKVs, maxDepth-1)
|
||||
grpKVs = attrToKVs(attr, grpKVs)
|
||||
}
|
||||
if attr.Key == "" {
|
||||
// slog says we have to inline these
|
||||
|
||||
+1
@@ -1,4 +1,5 @@
|
||||
//go:build go1.21
|
||||
// +build go1.21
|
||||
|
||||
/*
|
||||
Copyright 2023 The logr Authors.
|
||||
|
||||
+1
@@ -1,4 +1,5 @@
|
||||
//go:build go1.21
|
||||
// +build go1.21
|
||||
|
||||
/*
|
||||
Copyright 2023 The logr Authors.
|
||||
|
||||
+1
@@ -1,4 +1,5 @@
|
||||
//go:build go1.21
|
||||
// +build go1.21
|
||||
|
||||
/*
|
||||
Copyright 2023 The logr Authors.
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
* -text
|
||||
*.bin -text -diff
|
||||
*.md text eol=lf
|
||||
+1
-10
@@ -31,9 +31,6 @@ builds:
|
||||
- mips64le
|
||||
goarm:
|
||||
- 7
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
-
|
||||
id: "s2d"
|
||||
binary: s2d
|
||||
@@ -60,9 +57,6 @@ builds:
|
||||
- mips64le
|
||||
goarm:
|
||||
- 7
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
-
|
||||
id: "s2sx"
|
||||
binary: s2sx
|
||||
@@ -90,9 +84,6 @@ builds:
|
||||
- mips64le
|
||||
goarm:
|
||||
- 7
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
|
||||
archives:
|
||||
-
|
||||
@@ -100,7 +91,7 @@ archives:
|
||||
name_template: "s2-{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats: ['zip']
|
||||
format: zip
|
||||
files:
|
||||
- unpack/*
|
||||
- s2/LICENSE
|
||||
|
||||
+693
-700
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -407,8 +407,8 @@ func histogramSplit(b []byte, h []uint16) {
|
||||
for i, t := range x {
|
||||
v0 := &h[t]
|
||||
v1 := &h[y[i]]
|
||||
v2 := &h[z[i]]
|
||||
v3 := &h[w[i]]
|
||||
v2 := &h[z[i]]
|
||||
*v0++
|
||||
*v1++
|
||||
*v2++
|
||||
|
||||
+1
@@ -1,4 +1,5 @@
|
||||
//go:build !amd64
|
||||
// +build !amd64
|
||||
|
||||
package flate
|
||||
|
||||
|
||||
+78
-78
@@ -1,79 +1,79 @@
|
||||
# Finite State Entropy
|
||||
|
||||
This package provides Finite State Entropy encoding and decoding.
|
||||
|
||||
Finite State Entropy (also referenced as [tANS](https://en.wikipedia.org/wiki/Asymmetric_numeral_systems#tANS))
|
||||
encoding provides a fast near-optimal symbol encoding/decoding
|
||||
for byte blocks as implemented in [zstandard](https://github.com/facebook/zstd).
|
||||
|
||||
This can be used for compressing input with a lot of similar input values to the smallest number of bytes.
|
||||
This does not perform any multi-byte [dictionary coding](https://en.wikipedia.org/wiki/Dictionary_coder) as LZ coders,
|
||||
but it can be used as a secondary step to compressors (like Snappy) that does not do entropy encoding.
|
||||
|
||||
* [Godoc documentation](https://godoc.org/github.com/klauspost/compress/fse)
|
||||
|
||||
## News
|
||||
|
||||
* Feb 2018: First implementation released. Consider this beta software for now.
|
||||
|
||||
# Usage
|
||||
|
||||
This package provides a low level interface that allows to compress single independent blocks.
|
||||
|
||||
Each block is separate, and there is no built in integrity checks.
|
||||
This means that the caller should keep track of block sizes and also do checksums if needed.
|
||||
|
||||
Compressing a block is done via the [`Compress`](https://godoc.org/github.com/klauspost/compress/fse#Compress) function.
|
||||
You must provide input and will receive the output and maybe an error.
|
||||
|
||||
These error values can be returned:
|
||||
|
||||
| Error | Description |
|
||||
|---------------------|-----------------------------------------------------------------------------|
|
||||
| `<nil>` | Everything ok, output is returned |
|
||||
| `ErrIncompressible` | Returned when input is judged to be too hard to compress |
|
||||
| `ErrUseRLE` | Returned from the compressor when the input is a single byte value repeated |
|
||||
| `(error)` | An internal error occurred. |
|
||||
|
||||
As can be seen above there are errors that will be returned even under normal operation so it is important to handle these.
|
||||
|
||||
To reduce allocations you can provide a [`Scratch`](https://godoc.org/github.com/klauspost/compress/fse#Scratch) object
|
||||
that can be re-used for successive calls. Both compression and decompression accepts a `Scratch` object, and the same
|
||||
object can be used for both.
|
||||
|
||||
Be aware, that when re-using a `Scratch` object that the *output* buffer is also re-used, so if you are still using this
|
||||
you must set the `Out` field in the scratch to nil. The same buffer is used for compression and decompression output.
|
||||
|
||||
Decompressing is done by calling the [`Decompress`](https://godoc.org/github.com/klauspost/compress/fse#Decompress) function.
|
||||
You must provide the output from the compression stage, at exactly the size you got back. If you receive an error back
|
||||
your input was likely corrupted.
|
||||
|
||||
It is important to note that a successful decoding does *not* mean your output matches your original input.
|
||||
There are no integrity checks, so relying on errors from the decompressor does not assure your data is valid.
|
||||
|
||||
For more detailed usage, see examples in the [godoc documentation](https://godoc.org/github.com/klauspost/compress/fse#pkg-examples).
|
||||
|
||||
# Performance
|
||||
|
||||
A lot of factors are affecting speed. Block sizes and compressibility of the material are primary factors.
|
||||
All compression functions are currently only running on the calling goroutine so only one core will be used per block.
|
||||
|
||||
The compressor is significantly faster if symbols are kept as small as possible. The highest byte value of the input
|
||||
is used to reduce some of the processing, so if all your input is above byte value 64 for instance, it may be
|
||||
beneficial to transpose all your input values down by 64.
|
||||
|
||||
With moderate block sizes around 64k speed are typically 200MB/s per core for compression and
|
||||
around 300MB/s decompression speed.
|
||||
|
||||
The same hardware typically does Huffman (deflate) encoding at 125MB/s and decompression at 100MB/s.
|
||||
|
||||
# Plans
|
||||
|
||||
At one point, more internals will be exposed to facilitate more "expert" usage of the components.
|
||||
|
||||
A streaming interface is also likely to be implemented. Likely compatible with [FSE stream format](https://github.com/Cyan4973/FiniteStateEntropy/blob/dev/programs/fileio.c#L261).
|
||||
|
||||
# Contributing
|
||||
|
||||
Contributions are always welcome. Be aware that adding public functions will require good justification and breaking
|
||||
# Finite State Entropy
|
||||
|
||||
This package provides Finite State Entropy encoding and decoding.
|
||||
|
||||
Finite State Entropy (also referenced as [tANS](https://en.wikipedia.org/wiki/Asymmetric_numeral_systems#tANS))
|
||||
encoding provides a fast near-optimal symbol encoding/decoding
|
||||
for byte blocks as implemented in [zstandard](https://github.com/facebook/zstd).
|
||||
|
||||
This can be used for compressing input with a lot of similar input values to the smallest number of bytes.
|
||||
This does not perform any multi-byte [dictionary coding](https://en.wikipedia.org/wiki/Dictionary_coder) as LZ coders,
|
||||
but it can be used as a secondary step to compressors (like Snappy) that does not do entropy encoding.
|
||||
|
||||
* [Godoc documentation](https://godoc.org/github.com/klauspost/compress/fse)
|
||||
|
||||
## News
|
||||
|
||||
* Feb 2018: First implementation released. Consider this beta software for now.
|
||||
|
||||
# Usage
|
||||
|
||||
This package provides a low level interface that allows to compress single independent blocks.
|
||||
|
||||
Each block is separate, and there is no built in integrity checks.
|
||||
This means that the caller should keep track of block sizes and also do checksums if needed.
|
||||
|
||||
Compressing a block is done via the [`Compress`](https://godoc.org/github.com/klauspost/compress/fse#Compress) function.
|
||||
You must provide input and will receive the output and maybe an error.
|
||||
|
||||
These error values can be returned:
|
||||
|
||||
| Error | Description |
|
||||
|---------------------|-----------------------------------------------------------------------------|
|
||||
| `<nil>` | Everything ok, output is returned |
|
||||
| `ErrIncompressible` | Returned when input is judged to be too hard to compress |
|
||||
| `ErrUseRLE` | Returned from the compressor when the input is a single byte value repeated |
|
||||
| `(error)` | An internal error occurred. |
|
||||
|
||||
As can be seen above there are errors that will be returned even under normal operation so it is important to handle these.
|
||||
|
||||
To reduce allocations you can provide a [`Scratch`](https://godoc.org/github.com/klauspost/compress/fse#Scratch) object
|
||||
that can be re-used for successive calls. Both compression and decompression accepts a `Scratch` object, and the same
|
||||
object can be used for both.
|
||||
|
||||
Be aware, that when re-using a `Scratch` object that the *output* buffer is also re-used, so if you are still using this
|
||||
you must set the `Out` field in the scratch to nil. The same buffer is used for compression and decompression output.
|
||||
|
||||
Decompressing is done by calling the [`Decompress`](https://godoc.org/github.com/klauspost/compress/fse#Decompress) function.
|
||||
You must provide the output from the compression stage, at exactly the size you got back. If you receive an error back
|
||||
your input was likely corrupted.
|
||||
|
||||
It is important to note that a successful decoding does *not* mean your output matches your original input.
|
||||
There are no integrity checks, so relying on errors from the decompressor does not assure your data is valid.
|
||||
|
||||
For more detailed usage, see examples in the [godoc documentation](https://godoc.org/github.com/klauspost/compress/fse#pkg-examples).
|
||||
|
||||
# Performance
|
||||
|
||||
A lot of factors are affecting speed. Block sizes and compressibility of the material are primary factors.
|
||||
All compression functions are currently only running on the calling goroutine so only one core will be used per block.
|
||||
|
||||
The compressor is significantly faster if symbols are kept as small as possible. The highest byte value of the input
|
||||
is used to reduce some of the processing, so if all your input is above byte value 64 for instance, it may be
|
||||
beneficial to transpose all your input values down by 64.
|
||||
|
||||
With moderate block sizes around 64k speed are typically 200MB/s per core for compression and
|
||||
around 300MB/s decompression speed.
|
||||
|
||||
The same hardware typically does Huffman (deflate) encoding at 125MB/s and decompression at 100MB/s.
|
||||
|
||||
# Plans
|
||||
|
||||
At one point, more internals will be exposed to facilitate more "expert" usage of the components.
|
||||
|
||||
A streaming interface is also likely to be implemented. Likely compatible with [FSE stream format](https://github.com/Cyan4973/FiniteStateEntropy/blob/dev/programs/fileio.c#L261).
|
||||
|
||||
# Contributing
|
||||
|
||||
Contributions are always welcome. Be aware that adding public functions will require good justification and breaking
|
||||
changes will likely not be accepted. If in doubt open an issue before writing the PR.
|
||||
+89
-89
@@ -1,89 +1,89 @@
|
||||
# Huff0 entropy compression
|
||||
|
||||
This package provides Huff0 encoding and decoding as used in zstd.
|
||||
|
||||
[Huff0](https://github.com/Cyan4973/FiniteStateEntropy#new-generation-entropy-coders),
|
||||
a Huffman codec designed for modern CPU, featuring OoO (Out of Order) operations on multiple ALU
|
||||
(Arithmetic Logic Unit), achieving extremely fast compression and decompression speeds.
|
||||
|
||||
This can be used for compressing input with a lot of similar input values to the smallest number of bytes.
|
||||
This does not perform any multi-byte [dictionary coding](https://en.wikipedia.org/wiki/Dictionary_coder) as LZ coders,
|
||||
but it can be used as a secondary step to compressors (like Snappy) that does not do entropy encoding.
|
||||
|
||||
* [Godoc documentation](https://godoc.org/github.com/klauspost/compress/huff0)
|
||||
|
||||
## News
|
||||
|
||||
This is used as part of the [zstandard](https://github.com/klauspost/compress/tree/master/zstd#zstd) compression and decompression package.
|
||||
|
||||
This ensures that most functionality is well tested.
|
||||
|
||||
# Usage
|
||||
|
||||
This package provides a low level interface that allows to compress single independent blocks.
|
||||
|
||||
Each block is separate, and there is no built in integrity checks.
|
||||
This means that the caller should keep track of block sizes and also do checksums if needed.
|
||||
|
||||
Compressing a block is done via the [`Compress1X`](https://godoc.org/github.com/klauspost/compress/huff0#Compress1X) and
|
||||
[`Compress4X`](https://godoc.org/github.com/klauspost/compress/huff0#Compress4X) functions.
|
||||
You must provide input and will receive the output and maybe an error.
|
||||
|
||||
These error values can be returned:
|
||||
|
||||
| Error | Description |
|
||||
|---------------------|-----------------------------------------------------------------------------|
|
||||
| `<nil>` | Everything ok, output is returned |
|
||||
| `ErrIncompressible` | Returned when input is judged to be too hard to compress |
|
||||
| `ErrUseRLE` | Returned from the compressor when the input is a single byte value repeated |
|
||||
| `ErrTooBig` | Returned if the input block exceeds the maximum allowed size (128 Kib) |
|
||||
| `(error)` | An internal error occurred. |
|
||||
|
||||
|
||||
As can be seen above some of there are errors that will be returned even under normal operation so it is important to handle these.
|
||||
|
||||
To reduce allocations you can provide a [`Scratch`](https://godoc.org/github.com/klauspost/compress/huff0#Scratch) object
|
||||
that can be re-used for successive calls. Both compression and decompression accepts a `Scratch` object, and the same
|
||||
object can be used for both.
|
||||
|
||||
Be aware, that when re-using a `Scratch` object that the *output* buffer is also re-used, so if you are still using this
|
||||
you must set the `Out` field in the scratch to nil. The same buffer is used for compression and decompression output.
|
||||
|
||||
The `Scratch` object will retain state that allows to re-use previous tables for encoding and decoding.
|
||||
|
||||
## Tables and re-use
|
||||
|
||||
Huff0 allows for reusing tables from the previous block to save space if that is expected to give better/faster results.
|
||||
|
||||
The Scratch object allows you to set a [`ReusePolicy`](https://godoc.org/github.com/klauspost/compress/huff0#ReusePolicy)
|
||||
that controls this behaviour. See the documentation for details. This can be altered between each block.
|
||||
|
||||
Do however note that this information is *not* stored in the output block and it is up to the users of the package to
|
||||
record whether [`ReadTable`](https://godoc.org/github.com/klauspost/compress/huff0#ReadTable) should be called,
|
||||
based on the boolean reported back from the CompressXX call.
|
||||
|
||||
If you want to store the table separate from the data, you can access them as `OutData` and `OutTable` on the
|
||||
[`Scratch`](https://godoc.org/github.com/klauspost/compress/huff0#Scratch) object.
|
||||
|
||||
## Decompressing
|
||||
|
||||
The first part of decoding is to initialize the decoding table through [`ReadTable`](https://godoc.org/github.com/klauspost/compress/huff0#ReadTable).
|
||||
This will initialize the decoding tables.
|
||||
You can supply the complete block to `ReadTable` and it will return the data part of the block
|
||||
which can be given to the decompressor.
|
||||
|
||||
Decompressing is done by calling the [`Decompress1X`](https://godoc.org/github.com/klauspost/compress/huff0#Scratch.Decompress1X)
|
||||
or [`Decompress4X`](https://godoc.org/github.com/klauspost/compress/huff0#Scratch.Decompress4X) function.
|
||||
|
||||
For concurrently decompressing content with a fixed table a stateless [`Decoder`](https://godoc.org/github.com/klauspost/compress/huff0#Decoder) can be requested which will remain correct as long as the scratch is unchanged. The capacity of the provided slice indicates the expected output size.
|
||||
|
||||
You must provide the output from the compression stage, at exactly the size you got back. If you receive an error back
|
||||
your input was likely corrupted.
|
||||
|
||||
It is important to note that a successful decoding does *not* mean your output matches your original input.
|
||||
There are no integrity checks, so relying on errors from the decompressor does not assure your data is valid.
|
||||
|
||||
# Contributing
|
||||
|
||||
Contributions are always welcome. Be aware that adding public functions will require good justification and breaking
|
||||
changes will likely not be accepted. If in doubt open an issue before writing the PR.
|
||||
# Huff0 entropy compression
|
||||
|
||||
This package provides Huff0 encoding and decoding as used in zstd.
|
||||
|
||||
[Huff0](https://github.com/Cyan4973/FiniteStateEntropy#new-generation-entropy-coders),
|
||||
a Huffman codec designed for modern CPU, featuring OoO (Out of Order) operations on multiple ALU
|
||||
(Arithmetic Logic Unit), achieving extremely fast compression and decompression speeds.
|
||||
|
||||
This can be used for compressing input with a lot of similar input values to the smallest number of bytes.
|
||||
This does not perform any multi-byte [dictionary coding](https://en.wikipedia.org/wiki/Dictionary_coder) as LZ coders,
|
||||
but it can be used as a secondary step to compressors (like Snappy) that does not do entropy encoding.
|
||||
|
||||
* [Godoc documentation](https://godoc.org/github.com/klauspost/compress/huff0)
|
||||
|
||||
## News
|
||||
|
||||
This is used as part of the [zstandard](https://github.com/klauspost/compress/tree/master/zstd#zstd) compression and decompression package.
|
||||
|
||||
This ensures that most functionality is well tested.
|
||||
|
||||
# Usage
|
||||
|
||||
This package provides a low level interface that allows to compress single independent blocks.
|
||||
|
||||
Each block is separate, and there is no built in integrity checks.
|
||||
This means that the caller should keep track of block sizes and also do checksums if needed.
|
||||
|
||||
Compressing a block is done via the [`Compress1X`](https://godoc.org/github.com/klauspost/compress/huff0#Compress1X) and
|
||||
[`Compress4X`](https://godoc.org/github.com/klauspost/compress/huff0#Compress4X) functions.
|
||||
You must provide input and will receive the output and maybe an error.
|
||||
|
||||
These error values can be returned:
|
||||
|
||||
| Error | Description |
|
||||
|---------------------|-----------------------------------------------------------------------------|
|
||||
| `<nil>` | Everything ok, output is returned |
|
||||
| `ErrIncompressible` | Returned when input is judged to be too hard to compress |
|
||||
| `ErrUseRLE` | Returned from the compressor when the input is a single byte value repeated |
|
||||
| `ErrTooBig` | Returned if the input block exceeds the maximum allowed size (128 Kib) |
|
||||
| `(error)` | An internal error occurred. |
|
||||
|
||||
|
||||
As can be seen above some of there are errors that will be returned even under normal operation so it is important to handle these.
|
||||
|
||||
To reduce allocations you can provide a [`Scratch`](https://godoc.org/github.com/klauspost/compress/huff0#Scratch) object
|
||||
that can be re-used for successive calls. Both compression and decompression accepts a `Scratch` object, and the same
|
||||
object can be used for both.
|
||||
|
||||
Be aware, that when re-using a `Scratch` object that the *output* buffer is also re-used, so if you are still using this
|
||||
you must set the `Out` field in the scratch to nil. The same buffer is used for compression and decompression output.
|
||||
|
||||
The `Scratch` object will retain state that allows to re-use previous tables for encoding and decoding.
|
||||
|
||||
## Tables and re-use
|
||||
|
||||
Huff0 allows for reusing tables from the previous block to save space if that is expected to give better/faster results.
|
||||
|
||||
The Scratch object allows you to set a [`ReusePolicy`](https://godoc.org/github.com/klauspost/compress/huff0#ReusePolicy)
|
||||
that controls this behaviour. See the documentation for details. This can be altered between each block.
|
||||
|
||||
Do however note that this information is *not* stored in the output block and it is up to the users of the package to
|
||||
record whether [`ReadTable`](https://godoc.org/github.com/klauspost/compress/huff0#ReadTable) should be called,
|
||||
based on the boolean reported back from the CompressXX call.
|
||||
|
||||
If you want to store the table separate from the data, you can access them as `OutData` and `OutTable` on the
|
||||
[`Scratch`](https://godoc.org/github.com/klauspost/compress/huff0#Scratch) object.
|
||||
|
||||
## Decompressing
|
||||
|
||||
The first part of decoding is to initialize the decoding table through [`ReadTable`](https://godoc.org/github.com/klauspost/compress/huff0#ReadTable).
|
||||
This will initialize the decoding tables.
|
||||
You can supply the complete block to `ReadTable` and it will return the data part of the block
|
||||
which can be given to the decompressor.
|
||||
|
||||
Decompressing is done by calling the [`Decompress1X`](https://godoc.org/github.com/klauspost/compress/huff0#Scratch.Decompress1X)
|
||||
or [`Decompress4X`](https://godoc.org/github.com/klauspost/compress/huff0#Scratch.Decompress4X) function.
|
||||
|
||||
For concurrently decompressing content with a fixed table a stateless [`Decoder`](https://godoc.org/github.com/klauspost/compress/huff0#Decoder) can be requested which will remain correct as long as the scratch is unchanged. The capacity of the provided slice indicates the expected output size.
|
||||
|
||||
You must provide the output from the compression stage, at exactly the size you got back. If you receive an error back
|
||||
your input was likely corrupted.
|
||||
|
||||
It is important to note that a successful decoding does *not* mean your output matches your original input.
|
||||
There are no integrity checks, so relying on errors from the decompressor does not assure your data is valid.
|
||||
|
||||
# Contributing
|
||||
|
||||
Contributions are always welcome. Be aware that adding public functions will require good justification and breaking
|
||||
changes will likely not be accepted. If in doubt open an issue before writing the PR.
|
||||
+1
@@ -1,4 +1,5 @@
|
||||
//go:build amd64 && !appengine && !noasm && gc
|
||||
// +build amd64,!appengine,!noasm,gc
|
||||
|
||||
// This file contains the specialisation of Decoder.Decompress4X
|
||||
// and Decoder.Decompress1X that use an asm implementation of thir main loops.
|
||||
|
||||
+1
@@ -1,4 +1,5 @@
|
||||
//go:build !amd64 || appengine || !gc || noasm
|
||||
// +build !amd64 appengine !gc noasm
|
||||
|
||||
// This file contains a generic implementation of Decoder.Decompress4X.
|
||||
package huff0
|
||||
|
||||
Loaded 100 of 419 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user