detached afc6dac

feat: add SecretBuffer and runtimesecret wrapper

commit
afc6dacbb01009776a3daca8089d122aec7c48ff
author
brice · · 2026-10-01 18:20 UTC
parent
e904307
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +166 -0
M go.mod +2 -0
@@ -4,6 +4,8 @@ go 1.27
44
55 require (
66 github.com/bnema/go-wayland-bindings v0.1.0
7+ github.com/bnema/purego v0.13.0-bnema.1 // indirect
8+ github.com/bnema/purego-xkbcommon v0.1.0
79 github.com/bnema/wlturbo v0.6.0
810 github.com/stretchr/testify v1.12.1
911 golang.org/x/sys v0.48.0
@@ -4,6 +4,8 @@ go 1.27
4 4
5 require (5 require (
6 github.com/bnema/go-wayland-bindings v0.1.06 github.com/bnema/go-wayland-bindings v0.1.0
7+ github.com/bnema/purego v0.13.0-bnema.1 // indirect
8+ github.com/bnema/purego-xkbcommon v0.1.0
7 github.com/bnema/wlturbo v0.6.09 github.com/bnema/wlturbo v0.6.0
8 github.com/stretchr/testify v1.12.110 github.com/stretchr/testify v1.12.1
9 golang.org/x/sys v0.48.011 golang.org/x/sys v0.48.0
A internal/secret/do_off.go +10 -0
@@ -0,0 +1,10 @@
1+//go:build !goexperiment.runtimesecret
2+
3+// Package secret runs key translation that touches secret text under
4+// runtime/secret when the toolchain provides it (GOEXPERIMENT=runtimesecret),
5+// and plainly otherwise.
6+package secret
7+
8+// Do runs f. Without GOEXPERIMENT=runtimesecret there is nothing to erase
9+// beyond what the caller wipes itself.
10+func Do(f func()) { f() }
@@ -0,0 +1,10 @@
1+//go:build !goexperiment.runtimesecret
2+
3+// Package secret runs key translation that touches secret text under
4+// runtime/secret when the toolchain provides it (GOEXPERIMENT=runtimesecret),
5+// and plainly otherwise.
6+package secret
7+
8+// Do runs f. Without GOEXPERIMENT=runtimesecret there is nothing to erase
9+// beyond what the caller wipes itself.
10+func Do(f func()) { f() }
A internal/secret/do_on.go +11 -0
@@ -0,0 +1,11 @@
1+//go:build goexperiment.runtimesecret
2+
3+// Package secret runs key translation that touches secret text under
4+// runtime/secret when the toolchain provides it (GOEXPERIMENT=runtimesecret),
5+// and plainly otherwise.
6+package secret
7+
8+import "runtime/secret"
9+
10+// Do runs f with its registers, stack and heap temporaries erased afterwards.
11+func Do(f func()) { secret.Do(f) }
@@ -0,0 +1,11 @@
1+//go:build goexperiment.runtimesecret
2+
3+// Package secret runs key translation that touches secret text under
4+// runtime/secret when the toolchain provides it (GOEXPERIMENT=runtimesecret),
5+// and plainly otherwise.
6+package secret
7+
8+import "runtime/secret"
9+
10+// Do runs f with its registers, stack and heap temporaries erased afterwards.
11+func Do(f func()) { secret.Do(f) }
A secret.go +92 -0
@@ -0,0 +1,92 @@
1+package neferclient
2+
3+import "unicode/utf8"
4+
5+// maxSecretCapacity bounds [NewSecretBuffer].
6+const maxSecretCapacity = 4096
7+
8+// SecretBuffer collects text typed while it is installed with
9+// [Seat.SetSecret], for example a password. Its storage is allocated once at
10+// full size, so it never reallocates and leaves no stale copies behind; the
11+// text is never turned into a string by the library.
12+//
13+// Only the owner goroutine (the one calling [Conn.Dispatch]) may use it.
14+type SecretBuffer struct {
15+ buf []byte // capacity*utf8.UTFMax bytes, fixed
16+ n int // bytes in use
17+ points int // code points in use
18+ max int // capacity in code points
19+}
20+
21+// NewSecretBuffer returns a buffer holding up to capacity code points.
22+// capacity is clamped to 1..4096.
23+func NewSecretBuffer(capacity int) *SecretBuffer {
24+ capacity = min(max(capacity, 1), maxSecretCapacity)
25+ return &SecretBuffer{buf: make([]byte, capacity*utf8.UTFMax), max: capacity}
26+}
27+
28+// Len returns the number of code points held.
29+func (b *SecretBuffer) Len() int {
30+ if b == nil {
31+ return 0
32+ }
33+ return b.points
34+}
35+
36+// Bytes returns the UTF-8 text held. The slice aliases the buffer: do not
37+// retain it, convert it to a string or log it; it changes on the next edit.
38+func (b *SecretBuffer) Bytes() []byte {
39+ if b == nil {
40+ return nil
41+ }
42+ return b.buf[:b.n:b.n]
43+}
44+
45+// Wipe zeroes the whole storage and empties the buffer.
46+func (b *SecretBuffer) Wipe() {
47+ if b == nil {
48+ return
49+ }
50+ clear(b.buf)
51+ b.n, b.points = 0, 0
52+}
53+
54+// appendText adds text, which must be valid UTF-8. It reports false and
55+// changes nothing when the text does not fit.
56+func (b *SecretBuffer) appendText(text []byte) bool {
57+ cp := utf8.RuneCount(text)
58+ if cp == 0 || b.points+cp > b.max {
59+ return false
60+ }
61+ b.n += copy(b.buf[b.n:], text)
62+ b.points += cp
63+ return true
64+}
65+
66+// backspace removes the last code point and wipes its bytes.
67+func (b *SecretBuffer) backspace() bool {
68+ if b.points == 0 {
69+ return false
70+ }
71+ _, size := utf8.DecodeLastRune(b.buf[:b.n])
72+ clear(b.buf[b.n-size : b.n])
73+ b.n -= size
74+ b.points--
75+ return true
76+}
77+
78+// classifySecretText reports whether text is printable input for a secret:
79+// non-empty, valid UTF-8 and free of C0 controls and DEL.
80+func classifySecretText(text []byte) bool {
81+ if len(text) == 0 || !utf8.Valid(text) {
82+ return false
83+ }
84+ for rest := text; len(rest) > 0; {
85+ r, size := utf8.DecodeRune(rest)
86+ if r < 0x20 || r == 0x7f {
87+ return false
88+ }
89+ rest = rest[size:]
90+ }
91+ return true
92+}
@@ -0,0 +1,92 @@
1+package neferclient
2+
3+import "unicode/utf8"
4+
5+// maxSecretCapacity bounds [NewSecretBuffer].
6+const maxSecretCapacity = 4096
7+
8+// SecretBuffer collects text typed while it is installed with
9+// [Seat.SetSecret], for example a password. Its storage is allocated once at
10+// full size, so it never reallocates and leaves no stale copies behind; the
11+// text is never turned into a string by the library.
12+//
13+// Only the owner goroutine (the one calling [Conn.Dispatch]) may use it.
14+type SecretBuffer struct {
15+ buf []byte // capacity*utf8.UTFMax bytes, fixed
16+ n int // bytes in use
17+ points int // code points in use
18+ max int // capacity in code points
19+}
20+
21+// NewSecretBuffer returns a buffer holding up to capacity code points.
22+// capacity is clamped to 1..4096.
23+func NewSecretBuffer(capacity int) *SecretBuffer {
24+ capacity = min(max(capacity, 1), maxSecretCapacity)
25+ return &SecretBuffer{buf: make([]byte, capacity*utf8.UTFMax), max: capacity}
26+}
27+
28+// Len returns the number of code points held.
29+func (b *SecretBuffer) Len() int {
30+ if b == nil {
31+ return 0
32+ }
33+ return b.points
34+}
35+
36+// Bytes returns the UTF-8 text held. The slice aliases the buffer: do not
37+// retain it, convert it to a string or log it; it changes on the next edit.
38+func (b *SecretBuffer) Bytes() []byte {
39+ if b == nil {
40+ return nil
41+ }
42+ return b.buf[:b.n:b.n]
43+}
44+
45+// Wipe zeroes the whole storage and empties the buffer.
46+func (b *SecretBuffer) Wipe() {
47+ if b == nil {
48+ return
49+ }
50+ clear(b.buf)
51+ b.n, b.points = 0, 0
52+}
53+
54+// appendText adds text, which must be valid UTF-8. It reports false and
55+// changes nothing when the text does not fit.
56+func (b *SecretBuffer) appendText(text []byte) bool {
57+ cp := utf8.RuneCount(text)
58+ if cp == 0 || b.points+cp > b.max {
59+ return false
60+ }
61+ b.n += copy(b.buf[b.n:], text)
62+ b.points += cp
63+ return true
64+}
65+
66+// backspace removes the last code point and wipes its bytes.
67+func (b *SecretBuffer) backspace() bool {
68+ if b.points == 0 {
69+ return false
70+ }
71+ _, size := utf8.DecodeLastRune(b.buf[:b.n])
72+ clear(b.buf[b.n-size : b.n])
73+ b.n -= size
74+ b.points--
75+ return true
76+}
77+
78+// classifySecretText reports whether text is printable input for a secret:
79+// non-empty, valid UTF-8 and free of C0 controls and DEL.
80+func classifySecretText(text []byte) bool {
81+ if len(text) == 0 || !utf8.Valid(text) {
82+ return false
83+ }
84+ for rest := text; len(rest) > 0; {
85+ r, size := utf8.DecodeRune(rest)
86+ if r < 0x20 || r == 0x7f {
87+ return false
88+ }
89+ rest = rest[size:]
90+ }
91+ return true
92+}
A secret_test.go +51 -0
@@ -0,0 +1,51 @@
1+package neferclient
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestSecretBufferEditing(t *testing.T) {
10+ b := NewSecretBuffer(4)
11+ storage := b.buf
12+ require.True(t, b.appendText([]byte("a")))
13+ require.True(t, b.appendText([]byte("é€")))
14+ require.Equal(t, 3, b.Len())
15+ require.Equal(t, []byte("aé€"), b.Bytes())
16+
17+ require.True(t, b.backspace()) // one code point, not one byte
18+ require.Equal(t, 2, b.Len())
19+ require.Equal(t, []byte("aé"), b.Bytes())
20+ require.Equal(t, make([]byte, 3), storage[3:6], "removed bytes are wiped")
21+
22+ require.False(t, b.appendText([]byte("xyz")), "does not fit: nothing changes")
23+ require.Equal(t, []byte("aé"), b.Bytes())
24+ require.True(t, b.appendText([]byte("xy")))
25+ require.False(t, b.appendText([]byte("z")), "full")
26+
27+ b.Wipe()
28+ require.Zero(t, b.Len())
29+ require.Empty(t, b.Bytes())
30+ require.Equal(t, make([]byte, len(storage)), storage, "whole storage zeroed")
31+ require.False(t, b.backspace())
32+}
33+
34+func TestSecretBufferCapacityClampAndNil(t *testing.T) {
35+ require.Equal(t, 1, NewSecretBuffer(-5).max)
36+ require.Equal(t, 1, NewSecretBuffer(0).max)
37+ require.Equal(t, maxSecretCapacity, NewSecretBuffer(1<<20).max)
38+ var b *SecretBuffer
39+ require.Zero(t, b.Len())
40+ require.Nil(t, b.Bytes())
41+ b.Wipe()
42+}
43+
44+func TestClassifySecretText(t *testing.T) {
45+ for _, ok := range []string{"a", "é", "€", " ", "日本"} {
46+ require.True(t, classifySecretText([]byte(ok)), ok)
47+ }
48+ for _, bad := range [][]byte{nil, {}, {'\r'}, {0x1b}, {0x7f}, {'a', '\n'}, {0xff}, {0xc3}} {
49+ require.False(t, classifySecretText(bad), "%q", bad)
50+ }
51+}
@@ -0,0 +1,51 @@
1+package neferclient
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestSecretBufferEditing(t *testing.T) {
10+ b := NewSecretBuffer(4)
11+ storage := b.buf
12+ require.True(t, b.appendText([]byte("a")))
13+ require.True(t, b.appendText([]byte("é€")))
14+ require.Equal(t, 3, b.Len())
15+ require.Equal(t, []byte("aé€"), b.Bytes())
16+
17+ require.True(t, b.backspace()) // one code point, not one byte
18+ require.Equal(t, 2, b.Len())
19+ require.Equal(t, []byte("aé"), b.Bytes())
20+ require.Equal(t, make([]byte, 3), storage[3:6], "removed bytes are wiped")
21+
22+ require.False(t, b.appendText([]byte("xyz")), "does not fit: nothing changes")
23+ require.Equal(t, []byte("aé"), b.Bytes())
24+ require.True(t, b.appendText([]byte("xy")))
25+ require.False(t, b.appendText([]byte("z")), "full")
26+
27+ b.Wipe()
28+ require.Zero(t, b.Len())
29+ require.Empty(t, b.Bytes())
30+ require.Equal(t, make([]byte, len(storage)), storage, "whole storage zeroed")
31+ require.False(t, b.backspace())
32+}
33+
34+func TestSecretBufferCapacityClampAndNil(t *testing.T) {
35+ require.Equal(t, 1, NewSecretBuffer(-5).max)
36+ require.Equal(t, 1, NewSecretBuffer(0).max)
37+ require.Equal(t, maxSecretCapacity, NewSecretBuffer(1<<20).max)
38+ var b *SecretBuffer
39+ require.Zero(t, b.Len())
40+ require.Nil(t, b.Bytes())
41+ b.Wipe()
42+}
43+
44+func TestClassifySecretText(t *testing.T) {
45+ for _, ok := range []string{"a", "é", "€", " ", "日本"} {
46+ require.True(t, classifySecretText([]byte(ok)), ok)
47+ }
48+ for _, bad := range [][]byte{nil, {}, {'\r'}, {0x1b}, {0x7f}, {'a', '\n'}, {0xff}, {0xc3}} {
49+ require.False(t, classifySecretText(bad), "%q", bad)
50+ }
51+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 4

↑↓ moveenter openesc close