Compare commits

..
Author SHA1 Message Date
moloch-- da3347c8c3 Strip non-debug plain implant executables 2026-10-22 12:19:12 -07:00
419 changed files with 3941 additions and 28047 deletions

No files matched your search

-1
View File
@@ -9,7 +9,6 @@ run:
- osusergo
- netgo
- go_sqlite
- sliver_generate
# Raw implant entry points are Go templates; lint their supporting packages.
- sliver_lint
+2 -2
View File
@@ -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
+18 -4
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
package armory
+10 -9
View File
@@ -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
}
-54
View File
@@ -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)
}
}
}
+1 -1
View File
@@ -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.
+40 -60
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+16 -16
View File
@@ -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
+32 -30
View File
@@ -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=
+3 -10
View File
@@ -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{})
+30 -63
View File
@@ -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")
}
}
+1 -1
View File
@@ -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
View File
@@ -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) {
-407
View File
@@ -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
View File
@@ -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)
}
-297
View File
@@ -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
}
+173
View File
@@ -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
// }
+17 -42
View File
@@ -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 {
+19 -20
View File
@@ -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)
}
}
-52
View File
@@ -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
View File
@@ -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
-1
View File
@@ -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,
+14
View File
@@ -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)
}
+191 -50
View File
@@ -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)
}
}
}
+2 -21
View File
@@ -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
-2
View File
@@ -1,5 +1,3 @@
//go:build sliver_generate
package generate
/*
+6 -39
View File
@@ -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
-126
View File
@@ -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)
}
}
}
-199
View File
@@ -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)
}
})
}
}
-28
View File
@@ -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)
}
})
}
}
+19
View File
@@ -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/>.
*/
-2
View File
@@ -1,5 +1,3 @@
//go:build sliver_generate
package generate
/*
+1 -1
View File
@@ -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
+34
View File
@@ -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{
+23
View File
@@ -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{
-16
View File
@@ -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`.
+1 -2
View File
@@ -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
View File
@@ -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"`
-24
View File
@@ -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)
}
})
}
}
-125
View File
@@ -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
})
}
+1 -1
View File
@@ -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"
+34 -36
View File
@@ -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)
}
}
}
-3
View File
@@ -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
View File
@@ -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
View File
@@ -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}}
-1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
}
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -1,4 +1,5 @@
//go:build !go1.21
// +build !go1.21
/*
Copyright 2019 The logr Authors.
+1
View File
@@ -1,4 +1,5 @@
//go:build go1.21
// +build go1.21
/*
Copyright 2019 The logr Authors.
+11 -36
View File
@@ -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
View File
@@ -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
View File
@@ -1,4 +1,5 @@
//go:build go1.21
// +build go1.21
/*
Copyright 2023 The logr Authors.
+1
View File
@@ -1,4 +1,5 @@
//go:build go1.21
// +build go1.21
/*
Copyright 2023 The logr Authors.
+1
View File
@@ -1,4 +1,5 @@
//go:build go1.21
// +build go1.21
/*
Copyright 2023 The logr Authors.
-1
View File
@@ -1,3 +1,2 @@
* -text
*.bin -text -diff
*.md text eol=lf
+1 -10
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -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
View File
@@ -1,4 +1,5 @@
//go:build !amd64
// +build !amd64
package flate
+78 -78
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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