v0.1.0 c541a90

conversation_linux_test.go RawHistoryFolder

1//go:build linux && (amd64 || arm64)
2
3package pam
4
5import (
6	"errors"
7	"testing"
8	"time"
9	"unsafe"
10
11	"github.com/bnema/purego"
12	"github.com/stretchr/testify/mock"
13	"github.com/stretchr/testify/require"
14)
15
16func nativeMessages(t *testing.T, memory nativeMemory, styles ...int32) (unsafe.Pointer, unsafe.Pointer) {
17	t.Helper()
18	pointers, err := memory.alloc(uintptr(len(styles)) * 8)
19	require.NoError(t, err)
20	t.Cleanup(func() { memory.free(pointers) })
21	for i, style := range styles {
22		text, err := nativeCString(memory, "synthetic prompt")
23		require.NoError(t, err)
24		t.Cleanup(func() { memory.free(text) })
25		message, err := memory.alloc(16)
26		require.NoError(t, err)
27		t.Cleanup(func() { memory.free(message) })
28		*(*nativeMessage)(message) = nativeMessage{Style: style, Text: text}
29		unsafe.Slice((*unsafe.Pointer)(pointers), len(styles))[i] = message
30	}
31	output, err := memory.alloc(8)
32	require.NoError(t, err)
33	t.Cleanup(func() { memory.free(output) })
34	return pointers, output
35}
36
37func invokeConversation(t *testing.T, cookie uintptr, count int32, messages, output unsafe.Pointer) Status {
38	t.Helper()
39	address, err := callbackAddress()
40	require.NoError(t, err)
41	result, _, _ := purego.SyscallN(address, uintptr(count), uintptr(messages), uintptr(output), cookie)
42	return Status(int32(result))
43}
44
45func callbackState(t *testing.T, handler Conversation, memory nativeMemory) *conversationState {
46	t.Helper()
47	state, err := registerConversation(handler, memory)
48	require.NoError(t, err)
49	t.Cleanup(state.retire)
50	return state
51}
52
53func TestConversationCABIAndResponseOwnership(t *testing.T) {
54	l, _, _ := testLibrary(t)
55	handler := NewMockConversation(t)
56	s := callbackState(t, handler, l.memory)
57	messages, output := nativeMessages(t, l.memory, PromptEchoOff, PromptEchoOn, ErrorMsg, TextInfo)
58	var answers, prompts [][]byte
59	handler.EXPECT().Respond(mock.Anything, mock.Anything).RunAndReturn(func(message Message, answer []byte) (int, error) {
60		require.Equal(t, "synthetic prompt", string(message.Text))
61		require.Equal(t, make([]byte, MaxRespSize), answer)
62		answers = append(answers, answer)
63		prompts = append(prompts, message.Text)
64		if message.Style == PromptEchoOff || message.Style == PromptEchoOn {
65			copy(answer, "synthetic answer")
66			return 16, nil
67		}
68		return 0, nil
69	}).Times(4)
70	require.Equal(t, Success, invokeConversation(t, s.token, 4, messages, output))
71	for _, answer := range answers {
72		require.Equal(t, make([]byte, len(answer)), answer)
73	}
74	for _, prompt := range prompts {
75		require.Equal(t, make([]byte, len(prompt)), prompt)
76	}
77	responses := *(*unsafe.Pointer)(output)
78	require.NotNil(t, responses)
79	array := unsafe.Slice((*nativeResponse)(responses), 4)
80	for i := range array {
81		require.Zero(t, array[i].ReturnCode)
82		if i < 2 {
83			require.Equal(t, []byte("synthetic answer\x00"), unsafe.Slice((*byte)(array[i].Text), 17))
84			l.memory.free(array[i].Text) // simulate PAM ownership release
85		} else {
86			require.Nil(t, array[i].Text)
87		}
88	}
89	l.memory.free(responses)
90}
91
92func TestHandlerFailureAndBoundsWipeBuffers(t *testing.T) {
93	for _, scenario := range []string{"error", "panic", "negative", "oversize", "embedded-nul", "info-answer"} {
94		t.Run(scenario, func(t *testing.T) {
95			l, _, _ := testLibrary(t)
96			handler := NewMockConversation(t)
97			s := callbackState(t, handler, l.memory)
98			style := PromptEchoOff
99			if scenario == "info-answer" {
100				style = TextInfo
101			}
102			messages, output := nativeMessages(t, l.memory, style)
103			var capturedAnswer, capturedPrompt []byte
104			handler.EXPECT().Respond(mock.Anything, mock.Anything).RunAndReturn(func(message Message, answer []byte) (int, error) {
105				capturedAnswer, capturedPrompt = answer, message.Text
106				copy(answer, "synthetic")
107				switch scenario {
108				case "error":
109					return 9, errors.New("sensitive payload must not escape")
110				case "panic":
111					panic("sensitive panic must not escape")
112				case "negative":
113					return -1, nil
114				case "oversize":
115					return MaxRespSize + 1, nil
116				case "embedded-nul":
117					return 10, nil
118				default:
119					return 9, nil
120				}
121			}).Once()
122			require.Equal(t, ConvErr, invokeConversation(t, s.token, 1, messages, output))
123			require.Nil(t, *(*unsafe.Pointer)(output))
124			require.Equal(t, make([]byte, len(capturedAnswer)), capturedAnswer)
125			require.Equal(t, make([]byte, len(capturedPrompt)), capturedPrompt)
126		})
127	}
128}
129
130func TestNativeInputValidation(t *testing.T) {
131	for _, scenario := range []string{"zero", "too-many", "negative", "null-array", "null-output", "null-message", "null-text", "binary", "unterminated", "unaligned-array", "unaligned-output", "unaligned-message"} {
132		t.Run(scenario, func(t *testing.T) {
133			l, _, _ := testLibrary(t)
134			handler := NewMockConversation(t) // no Respond allowed
135			s := callbackState(t, handler, l.memory)
136			messages, output := nativeMessages(t, l.memory, PromptEchoOff)
137			message := (*nativeMessage)(*(*unsafe.Pointer)(messages))
138			count := int32(1)
139			switch scenario {
140			case "zero":
141				count = 0
142			case "too-many":
143				count = MaxNumMsg + 1
144			case "negative":
145				count = -1
146			case "null-array":
147				messages = nil
148			case "null-output":
149				output = nil
150			case "null-message":
151				*(*unsafe.Pointer)(messages) = nil
152			case "null-text":
153				message.Text = nil
154			case "binary":
155				message.Style = 7
156			case "unterminated":
157				p, err := l.memory.alloc(MaxMsgSize)
158				require.NoError(t, err)
159				t.Cleanup(func() { l.memory.free(p) })
160				for i := range unsafe.Slice((*byte)(p), MaxMsgSize) {
161					unsafe.Slice((*byte)(p), MaxMsgSize)[i] = 'x'
162				}
163				message.Text = p
164			case "unaligned-array":
165				messages = unsafe.Add(messages, 1)
166			case "unaligned-output":
167				output = unsafe.Add(output, 1)
168			case "unaligned-message":
169				*(*unsafe.Pointer)(messages) = unsafe.Add(unsafe.Pointer(message), 1)
170			}
171			require.Equal(t, ConvErr, invokeConversation(t, s.token, count, messages, output))
172		})
173	}
174	t.Run("unknown-cookie-does-not-touch-pointers", func(t *testing.T) {
175		l, _, _ := testLibrary(t)
176		s := callbackState(t, NewMockConversation(t), l.memory)
177		s.retire()
178		address, err := callbackAddress()
179		require.NoError(t, err)
180		// Deliberately invalid addresses never converted to Go pointers.
181		result, _, _ := purego.SyscallN(address, 1, 1, 1, s.token)
182		require.Equal(t, ConvErr, Status(int32(result)))
183	})
184}
185
186func TestAllocationFailuresCleanPartialNativeResponses(t *testing.T) {
187	for failAt := 0; failAt < 3; failAt++ {
188		t.Run(string(rune('0'+failAt)), func(t *testing.T) {
189			l, _, _ := testLibrary(t)
190			memory := newMocknativeMemory(t)
191			handler := NewMockConversation(t)
192			s := callbackState(t, handler, memory)
193			messages, output := nativeMessages(t, l.memory, PromptEchoOff, PromptEchoOn)
194			index := 0
195			allocated := make(map[unsafe.Pointer]uintptr)
196			memory.EXPECT().alloc(mock.Anything).RunAndReturn(func(size uintptr) (unsafe.Pointer, error) {
197				current := index
198				index++
199				if current == failAt {
200					return nil, ErrAllocation
201				}
202				p, err := l.memory.alloc(size)
203				allocated[p] = size
204				return p, err
205			}).Times(failAt + 1)
206			if failAt > 0 {
207				memory.EXPECT().free(mock.Anything).Run(func(p unsafe.Pointer) {
208					size, ok := allocated[p]
209					require.True(t, ok)
210					if size == 4 {
211						require.Equal(t, make([]byte, 4), unsafe.Slice((*byte)(p), 4))
212					}
213					delete(allocated, p)
214					l.memory.free(p)
215				}).Times(failAt)
216			}
217			var answers [][]byte
218			if failAt > 0 {
219				handler.EXPECT().Respond(mock.Anything, mock.Anything).RunAndReturn(func(_ Message, answer []byte) (int, error) {
220					answers = append(answers, answer)
221					copy(answer, "abc")
222					return 3, nil
223				}).Times(failAt)
224			}
225			require.Equal(t, BufErr, invokeConversation(t, s.token, 2, messages, output))
226			require.Nil(t, *(*unsafe.Pointer)(output))
227			require.Empty(t, allocated)
228			for _, answer := range answers {
229				require.Equal(t, make([]byte, len(answer)), answer)
230			}
231		})
232	}
233}
234
235func TestEndRetiresAfterNativeCleanupAndWaitsForCallbacks(t *testing.T) {
236	l, calls, handle := testLibrary(t)
237	handler := NewMockConversation(t)
238	messages, output := nativeMessages(t, l.memory, TextInfo)
239	var conv nativeCookieConversation
240	calls.EXPECT().Start(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(_, _, c, out unsafe.Pointer) Status {
241		conv = *(*nativeCookieConversation)(c)
242		*(*unsafe.Pointer)(out) = handle
243		return Success
244	}).Once()
245	tx, err := l.StartConversation("unit-service", "unit-user", handler)
246	require.NoError(t, err)
247	entered, release := make(chan struct{}), make(chan struct{})
248	handler.EXPECT().Respond(mock.Anything, mock.Anything).RunAndReturn(func(_ Message, _ []byte) (int, error) {
249		close(entered)
250		<-release
251		return 0, nil
252	}).Once()
253	callbackDone := make(chan Status, 1)
254	go func() { callbackDone <- invokeConversation(t, conv.Cookie, 1, messages, output) }()
255	select {
256	case <-entered:
257	case <-time.After(3 * time.Second):
258		t.Fatal("callback did not enter")
259	}
260	cleanupReturned := make(chan struct{})
261	calls.EXPECT().End(handle, Success).RunAndReturn(func(_ unsafe.Pointer, _ Status) Status {
262		// Admission must still be open during pam_end cleanup.
263		s := acquireConversation(conv.Cookie)
264		require.NotNil(t, s)
265		s.leave()
266		close(cleanupReturned)
267		return Success
268	}).Once()
269	endDone := make(chan error, 1)
270	go func() { endDone <- tx.End() }()
271	select {
272	case <-cleanupReturned:
273	case <-time.After(3 * time.Second):
274		t.Fatal("native End did not return")
275	}
276	select {
277	case <-endDone:
278		t.Fatal("End returned before callback finished")
279	default:
280	}
281	close(release)
282	select {
283	case status := <-callbackDone:
284		require.Equal(t, Success, status)
285	case <-time.After(3 * time.Second):
286		t.Fatal("callback did not finish")
287	}
288	select {
289	case err := <-endDone:
290		require.NoError(t, err)
291	case <-time.After(3 * time.Second):
292		t.Fatal("End did not finish")
293	}
294	require.Zero(t, l.active)
295	require.Empty(t, tx.storage)
296	require.Equal(t, ConvErr, invokeConversation(t, conv.Cookie, 1, nil, nil))
297	l.memory.free(*(*unsafe.Pointer)(output))
298	address, err := callbackAddress()
299	require.NoError(t, err)
300	require.Equal(t, conv.Function, address)
301}

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 1

↑↓ moveenter openesc close